├── .gitignore ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── SECURITY.md ├── SUPPORT.md ├── THIRD_PARTY_LICENSES.md ├── environment.yml ├── region_similarity ├── __init__.py ├── export.py ├── features.py ├── helpers.py ├── map.py ├── periods.py ├── regions.py ├── search.py ├── use_cases.py └── variables.py ├── requirements.txt ├── scripts └── app.py ├── setup.cfg ├── setup.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | # IDE settings 105 | .vscode/ 106 | .idea/ 107 | 108 | # Other 109 | public/ 110 | .editorconfig 111 | .DS_Store 112 | pyproject.toml 113 | .github/ 114 | credentials.json 115 | sims-key.json -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Python runtime as a parent image 2 | FROM python:3.12-slim 3 | 4 | # Set the working directory in the container 5 | WORKDIR /app 6 | 7 | # Set environment variables to reduce Python buffering and ensure UTF-8 encoding 8 | ENV PYTHONUNBUFFERED=1 \ 9 | PYTHONDONTWRITEBYTECODE=1 \ 10 | LANG=C.UTF-8 \ 11 | LC_ALL=C.UTF-8 12 | 13 | # Install necessary system dependencies 14 | RUN apt-get update && apt-get install -y \ 15 | build-essential \ 16 | libssl-dev \ 17 | libffi-dev \ 18 | python3-dev \ 19 | gdal-bin \ 20 | git \ 21 | wget \ 22 | unzip \ 23 | curl \ 24 | ca-certificates \ 25 | gnupg \ 26 | lsb-release \ 27 | dos2unix \ 28 | && rm -rf /var/lib/apt/lists/* 29 | 30 | # Install Miniforge with architecture detection 31 | RUN if [ "$(uname -m)" = "x86_64" ]; then \ 32 | curl -L --retry 5 --retry-delay 5 --retry-max-time 60 -o miniforge.sh https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Linux-x86_64.sh; \ 33 | elif [ "$(uname -m)" = "aarch64" ]; then \ 34 | curl -L --retry 5 --retry-delay 5 --retry-max-time 60 -o miniforge.sh https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Linux-aarch64.sh; \ 35 | else \ 36 | echo "Unsupported architecture: $(uname -m)" && exit 1; \ 37 | fi && \ 38 | chmod +x miniforge.sh && \ 39 | ./miniforge.sh -b -p /opt/conda && \ 40 | rm miniforge.sh 41 | 42 | # Add conda to path 43 | ENV PATH="/opt/conda/bin:$PATH" 44 | 45 | # Initialize conda and install mamba 46 | RUN conda init bash && \ 47 | conda install -n base -c conda-forge mamba -y 48 | 49 | # Create the environment and install GDAL 50 | RUN mamba create -n sims python=3.12.3 -y && \ 51 | mamba install -n sims -c conda-forge gdal -y 52 | 53 | # Install gcloud (Google Cloud SDK) 54 | RUN echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | tee -a /etc/apt/sources.list.d/google-cloud-sdk.list && \ 55 | curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key --keyring /usr/share/keyrings/cloud.google.gpg add - && \ 56 | apt-get update -y && \ 57 | apt-get install google-cloud-cli -y && \ 58 | apt-get clean && \ 59 | rm -rf /var/lib/apt/lists/* 60 | 61 | # Copy requirements first to leverage Docker cache 62 | COPY requirements.txt /app/ 63 | RUN conda run -n sims pip install -r requirements.txt 64 | 65 | # Copy the rest of the project files 66 | COPY . /app/ 67 | 68 | # Install the current package 69 | RUN conda run -n sims pip install -e . 70 | 71 | # Create a non-root user 72 | RUN useradd -m -s /bin/bash appuser && \ 73 | chown -R appuser:appuser /app 74 | USER appuser 75 | 76 | # Activate the conda environment 77 | SHELL ["/bin/bash", "--login", "-c"] 78 | RUN echo "conda activate sims" >> ~/.bashrc 79 | 80 | # Setup a healthcheck 81 | HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ 82 | CMD curl -f http://localhost:8080/ || exit 1 83 | 84 | # Expose the port for the app 85 | EXPOSE 8080 86 | 87 | # Create entrypoint script 88 | RUN echo '#!/bin/bash' > /app/entrypoint.sh && \ 89 | echo 'source /opt/conda/etc/profile.d/conda.sh' >> /app/entrypoint.sh && \ 90 | echo 'conda activate sims' >> /app/entrypoint.sh && \ 91 | echo '' >> /app/entrypoint.sh && \ 92 | echo '# If GOOGLE_CREDENTIALS environment variable exists, create a credentials file' >> /app/entrypoint.sh && \ 93 | echo 'if [ ! -z "$GOOGLE_CREDENTIALS" ]; then' >> /app/entrypoint.sh && \ 94 | echo ' echo "$GOOGLE_CREDENTIALS" | base64 -d > /app/credentials.json' >> /app/entrypoint.sh && \ 95 | echo ' export GOOGLE_APPLICATION_CREDENTIALS=/app/credentials.json' >> /app/entrypoint.sh && \ 96 | echo 'fi' >> /app/entrypoint.sh && \ 97 | echo '' >> /app/entrypoint.sh && \ 98 | echo '# Run the application' >> /app/entrypoint.sh && \ 99 | echo 'exec solara run scripts/app.py --production --host 0.0.0.0 --port 8080' >> /app/entrypoint.sh && \ 100 | chmod +x /app/entrypoint.sh && \ 101 | dos2unix /app/entrypoint.sh 102 | 103 | # Set the entrypoint 104 | ENTRYPOINT ["/app/entrypoint.sh"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | 7 | recursive-include tests * 8 | recursive-exclude * __pycache__ 9 | recursive-exclude * *.py[co] 10 | 11 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-build clean-pyc clean-test coverage dist docs help install lint lint/flake8 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import os, webbrowser, sys 6 | 7 | from urllib.request import pathname2url 8 | 9 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 10 | endef 11 | export BROWSER_PYSCRIPT 12 | 13 | define PRINT_HELP_PYSCRIPT 14 | import re, sys 15 | 16 | for line in sys.stdin: 17 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 18 | if match: 19 | target, help = match.groups() 20 | print("%-20s %s" % (target, help)) 21 | endef 22 | export PRINT_HELP_PYSCRIPT 23 | 24 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 25 | 26 | help: 27 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 28 | 29 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 30 | 31 | clean-build: ## remove build artifacts 32 | rm -fr build/ 33 | rm -fr dist/ 34 | rm -fr .eggs/ 35 | find . -name '*.egg-info' -exec rm -fr {} + 36 | find . -name '*.egg' -exec rm -f {} + 37 | 38 | clean-pyc: ## remove Python file artifacts 39 | find . -name '*.pyc' -exec rm -f {} + 40 | find . -name '*.pyo' -exec rm -f {} + 41 | find . -name '*~' -exec rm -f {} + 42 | find . -name '__pycache__' -exec rm -fr {} + 43 | 44 | clean-test: ## remove test and coverage artifacts 45 | rm -fr .tox/ 46 | rm -f .coverage 47 | rm -fr htmlcov/ 48 | rm -fr .pytest_cache 49 | 50 | lint/flake8: ## check style with flake8 51 | flake8 region_similarity tests 52 | 53 | lint: lint/flake8 ## check style 54 | 55 | test: ## run tests quickly with the default Python 56 | python setup.py test 57 | 58 | test-all: ## run tests on every Python version with tox 59 | tox 60 | 61 | coverage: ## check code coverage quickly with the default Python 62 | coverage run --source region_similarity setup.py test 63 | coverage report -m 64 | coverage html 65 | $(BROWSER) htmlcov/index.html 66 | 67 | docs: ## generate Sphinx HTML documentation, including API docs 68 | rm -f docs/region_similarity.rst 69 | rm -f docs/modules.rst 70 | sphinx-apidoc -o docs/ region_similarity 71 | $(MAKE) -C docs clean 72 | $(MAKE) -C docs html 73 | $(BROWSER) docs/_build/html/index.html 74 | 75 | servedocs: docs ## compile the docs watching for changes 76 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 77 | 78 | release: dist ## package and upload a release 79 | twine upload dist/* 80 | 81 | dist: clean ## builds source and wheel package 82 | python setup.py sdist 83 | python setup.py bdist_wheel 84 | ls -l dist 85 | 86 | install: clean ## install the package to the active Python's site-packages 87 | python setup.py install 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sims 2 | 3 | `Sims` is an interactive web tool that helps users find similar geographical regions or cluster areas based on environmental characteristics. Using Google Earth Engine as backend, it allows you to select a reference area and find other locations that share similar features like rainfall patterns, soil composition, vegetation indices, and land cover types. This makes it particularly valuable for agricultural planning, environmental research, and land-use analysis. 4 | 5 | ## GCP Setup 6 | 7 | Before running the application, you'll need to set up Google Cloud Platform credentials. Follow these steps: 8 | 9 | 1. Install the Google Cloud SDK (gcloud CLI): 10 | 11 | **Windows:** 12 | - Download the [Google Cloud SDK installer](https://dl.google.com/dl/cloudsdk/channels/rapid/GoogleCloudSDKInstaller.exe) 13 | - Run the installer and follow the prompts 14 | - Restart your terminal after installation 15 | 16 | **macOS:** 17 | ```bash 18 | # Using Homebrew 19 | brew install google-cloud-sdk 20 | ``` 21 | 22 | **Linux:** 23 | ```bash 24 | # Add Google Cloud SDK distribution URI as a package source 25 | echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | sudo tee -a /etc/apt/sources.list.d/google-cloud-sdk.list 26 | 27 | # Import the Google Cloud public key 28 | curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key --keyring /usr/share/keyrings/cloud.google.gpg add - 29 | 30 | # Update and install the SDK 31 | sudo apt-get update && sudo apt-get install google-cloud-sdk 32 | ``` 33 | 34 | 2. Initialize gcloud and authenticate: 35 | ```bash 36 | # Initialize gcloud 37 | gcloud init 38 | 39 | # Log in to your Google Account 40 | gcloud auth login 41 | 42 | # Set your project ID 43 | gcloud config set project YOUR_PROJECT_ID 44 | ``` 45 | 46 | 3. Create a service account and download credentials: 47 | ```bash 48 | # Create a service account 49 | gcloud iam service-accounts create sims-service-account --display-name="Sims Service Account" 50 | 51 | # Generate and download the key file 52 | gcloud iam service-accounts keys create sims-key.json --iam-account=sims-service-account@YOUR_PROJECT_ID.iam.gserviceaccount.com 53 | ``` 54 | 55 | 4. Set the environment variable to point to your credentials: 56 | ```bash 57 | # For Linux/macOS 58 | export GOOGLE_APPLICATION_CREDENTIALS="path/to/sims-key.json" 59 | 60 | # For Windows (PowerShell) 61 | $env:GOOGLE_APPLICATION_CREDENTIALS="path\to\sims-key.json" 62 | ``` 63 | 64 | 5. Grant necessary permissions: 65 | ```bash 66 | gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \ 67 | --member="serviceAccount:sims-service-account@YOUR_PROJECT_ID.iam.gserviceaccount.com" \ 68 | --role="roles/storage.objectViewer" 69 | ``` 70 | 71 | ## Local Development 72 | 73 | Install [`mamba`](https://github.com/conda-forge/miniforge). 74 | 75 | Setup a new environment, install the required dependencies, and install `Sims` as follows: 76 | 77 | ```bash 78 | mamba create -n sims python=3.12.3 79 | conda activate sims 80 | mamba install conda-forge::gdal 81 | pip install -r requirements.txt 82 | pip install -e . 83 | ``` 84 | 85 | You can run the app locally: 86 | ```bash 87 | export GOOGLE_APPLICATION_CREDENTIALS="$(pwd)/sims-key.json" 88 | cd scripts/ 89 | solara run app.py 90 | ``` 91 | 92 | ## Deploy 93 | 94 | You can deploy the app using Docker as follows: 95 | 96 | ### Build the Docker Image 97 | 98 | 1. Clone this repository or move the repository to your deployment machine. 99 | 2. Copy your GCP credentials file into the project directory: 100 | ```bash 101 | cp path/to/sims-key.json ./credentials.json 102 | ``` 103 | 104 | > ⚠️ Make sure to add `credentials.json` to your `.gitignore` file to avoid accidentally committing sensitive credentials. 105 | 106 | 3. Build the Docker image by running the following command in the project directory: 107 | ```bash 108 | docker build -t sims-app . 109 | ``` 110 | 111 | This will create a Docker image named `sims-app` based on the `Dockerfile` in the repository. 112 | 113 | ### Run the App 114 | 115 | You have two options for providing GCP credentials to the container: 116 | 117 | #### Option 1: Mount credentials file (Recommended for development) 118 | ```bash 119 | docker run -it \ 120 | -p 8080:8080 \ 121 | -v $(pwd)/credentials.json:/app/credentials.json \ 122 | -e GOOGLE_APPLICATION_CREDENTIALS=/app/credentials.json \ 123 | -e HOST="localhost:8080" \ 124 | sims-app 125 | ``` 126 | 127 | #### Option 2: Pass credentials as an environment variable (Recommended for production) 128 | ```bash 129 | # Export the content of your credentials file as a base64-encoded string 130 | export GOOGLE_CREDENTIALS=$(base64 -w 0 credentials.json) 131 | 132 | # Run the container with the encoded credentials 133 | docker run -it \ 134 | -p 8080:8080 \ 135 | -e GOOGLE_CREDENTIALS="$GOOGLE_CREDENTIALS" \ 136 | sims-app 137 | ``` 138 | 139 | > 📝 For Windows PowerShell, use this command to encode credentials: 140 | > ```powershell 141 | > $env:GOOGLE_CREDENTIALS = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((Get-Content -Raw credentials.json))) 142 | > ``` 143 | 144 | ### Access the App 145 | 146 | Once the app is running, you can access it from your browser by visiting: 147 | 148 | ``` 149 | http://localhost:8080 150 | ``` 151 | 152 | ## Contributing 153 | 154 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 155 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 156 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. 157 | 158 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 159 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 160 | provided by the bot. You will only need to do this once across all repos using our CLA. 161 | 162 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 163 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 164 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 165 | 166 | ## Trademarks 167 | 168 | This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft 169 | trademarks or logos is subject to and must follow 170 | [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). 171 | Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. 172 | Any use of third-party trademarks or logos are subject to those third-party's policies. 173 | 174 | ## License 175 | 176 | This project is licensed under the [MIT License](LICENSE). -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet) and [Xamarin](https://github.com/xamarin). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/security.md/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/security.md/msrc/pgp). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/security.md/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd). 40 | 41 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | ## How to file issues and get help 4 | 5 | This project uses GitHub Issues to track bugs and feature requests. Please search the existing 6 | issues before filing new issues to avoid duplicates. For new issues, file your bug or 7 | feature request as a new Issue. 8 | 9 | ## Microsoft Support Policy 10 | 11 | Support for this project is limited to the resources listed above. -------------------------------------------------------------------------------- /THIRD_PARTY_LICENSES.md: -------------------------------------------------------------------------------- 1 | # Third-Party Licenses 2 | 3 | This project uses the following third-party packages and libraries: 4 | 5 | 1. earthengine-api 6 | - License: Apache License 2.0 7 | - https://github.com/google/earthengine-api/blob/master/LICENSE 8 | 9 | 2. geemap 10 | - License: MIT License 11 | - https://github.com/giswqs/geemap/blob/master/LICENSE 12 | 13 | 3. shapely 14 | - License: BSD 3-Clause License 15 | - https://github.com/shapely/shapely/blob/main/LICENSE.txt 16 | 17 | 4. geopandas 18 | - License: BSD 3-Clause License 19 | - https://github.com/geopandas/geopandas/blob/main/LICENSE.txt 20 | 21 | 5. ipywidgets 22 | - License: BSD 3-Clause License 23 | - https://github.com/jupyter-widgets/ipywidgets/blob/main/LICENSE 24 | 25 | 6. ipyleaflet 26 | - License: MIT License 27 | - https://github.com/jupyter-widgets/ipyleaflet/blob/master/LICENSE 28 | 29 | 7. solara 30 | - License: MIT License 31 | - https://github.com/widgetti/solara/blob/master/LICENSE 32 | 33 | 8. python-dotenv 34 | - License: BSD 3-Clause License 35 | - https://github.com/theskumar/python-dotenv/blob/main/LICENSE 36 | 37 | 9. multiprocess 38 | - https://github.com/uqfoundation/multiprocess/blob/master/LICENSE 39 | 40 | 10. pyyaml 41 | - License: MIT License 42 | - https://github.com/yaml/pyyaml/blob/master/LICENSE 43 | 44 | 11. matplotlib 45 | - https://github.com/matplotlib/matplotlib/blob/main/LICENSE/LICENSE 46 | 47 | 12. numpy (implied dependency) 48 | - https://github.com/numpy/numpy/blob/main/LICENSE.txt 49 | 50 | 13. pandas (implied dependency) 51 | - License: BSD 3-Clause License 52 | - https://github.com/pandas-dev/pandas/blob/main/LICENSE 53 | 54 | 14. requests 55 | - License: Apache License 2.0 56 | - https://github.com/psf/requests/blob/main/LICENSE 57 | 58 | Please refer to the individual license files of each package for the full license text and terms. -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | channels: 2 | - conda-forge 3 | dependencies: 4 | - python=3.12.3 5 | - mamba 6 | - ca-certificates 7 | - certifi 8 | - openssl 9 | - numpy 10 | - pandas 11 | - gdal 12 | - geopandas 13 | - shapely 14 | - ipywidgets 15 | - ipyleaflet 16 | - pip 17 | - pip: 18 | - earthengine-api 19 | - geemap 20 | - solara 21 | - python-dotenv -------------------------------------------------------------------------------- /region_similarity/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | """Top-level package for region_similarity.""" 4 | 5 | __author__ = """Akram Zaytar""" 6 | __email__ = "akramzaytar@microsoft.com" 7 | __version__ = "0.1.0" 8 | -------------------------------------------------------------------------------- /region_similarity/export.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | """ 4 | This module provides functionality for exporting clusters, similarity, and feature map images. 5 | It includes utilities for compressing directories, generating file names, 6 | and exporting single or multiple images based on user-defined regions of interest. 7 | """ 8 | 9 | import hashlib 10 | import random 11 | import string 12 | import shutil 13 | import contextlib 14 | import io 15 | from pathlib import Path 16 | import ee 17 | import geemap 18 | from shapely.geometry import box, Polygon 19 | from region_similarity.helpers import message 20 | from solara.lab import task 21 | 22 | 23 | def compress_dir(dir_path, target_dir, delete=True): 24 | """ 25 | Compress a directory into a zip file. 26 | 27 | Args: 28 | dir_path (Path): Path to the directory to be compressed. 29 | target_dir (Path): Path to the directory where the zip file will be saved. 30 | delete (bool, optional): Whether to delete the original directory after compression. Defaults to True. 31 | 32 | Returns: 33 | Path: Path to the created zip file. 34 | """ 35 | zip_path = target_dir / dir_path.name 36 | shutil.make_archive(zip_path, "zip", dir_path) 37 | if delete: 38 | shutil.rmtree(dir_path) 39 | return zip_path 40 | 41 | 42 | def generate_random_hash(length=16): 43 | """ 44 | Generate a random hash string. 45 | 46 | Args: 47 | length (int, optional): Length of the random string to generate. Defaults to 16. 48 | 49 | Returns: 50 | str: A random hash string. 51 | """ 52 | random_string = "".join( 53 | random.choices(string.ascii_letters + string.digits, k=length) 54 | ) 55 | hash_object = hashlib.sha256(random_string.encode()) 56 | random_hash = hash_object.hexdigest() 57 | return random_hash 58 | 59 | 60 | @task 61 | def export_single_image(img, roi, resolution, m): 62 | """ 63 | Export a single Earth Engine image to a download URL. 64 | 65 | Args: 66 | img (ee.Image): Earth Engine image to export. 67 | roi (ee.Geometry): Region of interest to clip and export the image. 68 | resolution (float): Spatial resolution of the exported image in meters. 69 | m: Object containing message and URL information for status updates. 70 | 71 | The function attempts to generate a download URL for the image and displays 72 | status messages through the provided messaging object. If successful, it shows 73 | the download URL. If an error occurs, it displays the error message. 74 | """ 75 | message(m, f"Generating download URL...", False) 76 | 77 | try: 78 | url = img.getDownloadURL( 79 | { 80 | "scale": resolution, 81 | "region": roi, 82 | "format": "GEO_TIFF", 83 | "filePerBand": False, 84 | } 85 | ) 86 | 87 | message(m, f"Generating download URL...", True) 88 | message(m, f"Download link: {url}", False) 89 | message(m, f"Download link: {url}", True, duration=10) 90 | 91 | except Exception as e: 92 | message(m, f"Error generating download URL: {e}", False) 93 | message(m, f"Error generating download URL: {e}", True, duration=5) 94 | 95 | 96 | @task 97 | def export_multiple_images(cells, job_dir, img, resolution, m): 98 | """ 99 | Export multiple Earth Engine images to download URLs. 100 | 101 | Args: 102 | cells (list): List of Earth Engine geometries representing image cells. 103 | job_dir (Path): Directory to save the exported images (unused). 104 | img (ee.Image): Earth Engine image to export. 105 | resolution (float): Spatial resolution of the exported images in meters. 106 | m: Object containing message, URL, and progress bar information. 107 | """ 108 | message(m, f"Generating download URLs...", False) 109 | urls = [] 110 | for idx, cell in enumerate(cells): 111 | try: 112 | urls.append( 113 | img.clip(cell).getDownloadURL( 114 | { 115 | "scale": resolution, 116 | "region": cell, 117 | "format": "GEO_TIFF", 118 | "filePerBand": False, 119 | } 120 | ) 121 | ) 122 | except Exception as ex: 123 | message(m, f"Error generating URL for cell {idx+1}: {ex}", False) 124 | message(m, f"Error generating URL for cell {idx+1}: {ex}", True, 5) 125 | 126 | # Save URLs to a file 127 | random_hash = generate_random_hash() 128 | url_file = Path("../public") / f"urls_{random_hash}.txt" 129 | url_file.parent.mkdir(exist_ok=True) 130 | url_file.write_text("\n".join(urls)) 131 | 132 | # Share the URL file location 133 | message(m, f"Generating download URLs...", True) 134 | message( 135 | m, 136 | f"Download URLs available at: {m.url}/static/public/urls_{random_hash}.txt", 137 | False, 138 | ) 139 | message( 140 | m, 141 | f"Download URLs available at: {m.url}/static/public/urls_{random_hash}.txt", 142 | True, 143 | duration=10, 144 | ) 145 | 146 | 147 | def export_image(e, m): 148 | """ 149 | Export an image from Google Earth Engine to a local directory. 150 | 151 | This function handles the entire export process, including: 152 | - Setting up the download directory 153 | - Generating a unique job identifier 154 | - Preparing the Earth Engine image 155 | - Splitting the region of interest into cells if necessary 156 | - Exporting single or multiple images based on the region size 157 | 158 | Args: 159 | e: Earth Engine object. 160 | m: Map object containing user inputs and map elements. 161 | 162 | Returns: 163 | None 164 | """ 165 | 166 | try: 167 | 168 | # Set the downloads directory 169 | tmp = Path.home() / "tmp" 170 | tmp.mkdir(exist_ok=True) 171 | 172 | # Generate a random hash text for the job using hashlib 173 | job_dir = tmp / generate_random_hash() 174 | job_dir.mkdir(exist_ok=True) 175 | 176 | # Get the image based on the mode (clustering vs searching) 177 | result = m.clustered if m.cluster else m.distances 178 | 179 | # Get the features 180 | features = m.feature_img 181 | 182 | # Stack the features and the result 183 | img = ee.Image.cat([features, result]).reproject(crs='EPSG:4326', scale=1000) 184 | 185 | # Set the image resolution in meters 186 | resolution = 100 187 | 188 | # Convert the image resolution from meters to degrees 189 | meters_per_degree = 111320 # Approximation at the equator 190 | cell_size = 1000 * resolution / meters_per_degree 191 | 192 | # Get the bounding box of the ROI 193 | coords = m.qr.bounds().getInfo()["coordinates"][0] 194 | x_coords = [coord[0] for coord in coords] 195 | y_coords = [coord[1] for coord in coords] 196 | x_min, x_max = min(x_coords), max(x_coords) 197 | y_min, y_max = min(y_coords), max(y_coords) 198 | 199 | # Create a shapely polygon from `x_coords` and `y_coords` 200 | qr_geom = Polygon(zip(x_coords, y_coords)) 201 | 202 | # Calculate the number of cells needed 203 | x_cells = int((x_max - x_min) / cell_size) + 1 204 | y_cells = int((y_max - y_min) / cell_size) + 1 205 | 206 | def split_geometry(x_cells, y_cells): 207 | cells = [] 208 | for i in range(x_cells): 209 | for j in range(y_cells): 210 | 211 | # if the cell does not intersect the ROI, continue 212 | if not box( 213 | x_min + i * cell_size, 214 | y_min + j * cell_size, 215 | x_min + (i + 1) * cell_size, 216 | y_min + (j + 1) * cell_size, 217 | ).intersects(qr_geom): 218 | continue 219 | 220 | # Convert the shapely rectangle to an Earth Engine geometry 221 | cell = ee.Geometry.Rectangle( 222 | [ 223 | x_min + i * cell_size, 224 | y_min + j * cell_size, 225 | x_min + (i + 1) * cell_size, 226 | y_min + (j + 1) * cell_size, 227 | ] 228 | ) 229 | cells.append(cell) 230 | return cells 231 | 232 | # Export the image directly if only one cell is needed 233 | if x_cells <= 1 and y_cells <= 1: 234 | export_single_image(img, m.qr, resolution, m) 235 | return 236 | 237 | # Split the geometry and export each cell 238 | cells = split_geometry(x_cells, y_cells) 239 | export_multiple_images(cells, job_dir, img, resolution, m) 240 | 241 | except Exception as e: 242 | message(m, f"Error: {e}", False) 243 | message(m, f"Error: {e}", True, 5) 244 | -------------------------------------------------------------------------------- /region_similarity/features.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | """ 4 | This module contains functions for managing features (GEE expressions of aliases). 5 | It uses Earth Engine (ee) for geospatial operations and ipywidgets for UI components. 6 | """ 7 | 8 | import ee 9 | import ipywidgets as widgets 10 | from region_similarity.helpers import message 11 | from solara import display 12 | from solara.lab import task 13 | from multiprocess import Pool 14 | 15 | 16 | def remove_feature(feature, m): 17 | """ 18 | Remove a feature from the map and update the UI. 19 | 20 | Args: 21 | feature (str): The name of the feature to remove. 22 | m (object): The map object containing layers and features. 23 | """ 24 | # Remove the feature layer from the map 25 | m.layers = [layer for layer in m.layers if layer.name != feature] 26 | 27 | # Drop the feature from the features dictionary 28 | del m.features[feature] 29 | 30 | # Clear the output widget and print the added features 31 | with m.added_features_output: 32 | m.added_features_output.clear_output() 33 | for name, (expression, _) in m.features.items(): 34 | remove_button = widgets.Button( 35 | description="x", 36 | layout=widgets.Layout(width="20px", text_align="center", padding="0"), 37 | ) 38 | spacer = widgets.HBox([], layout=widgets.Layout(flex="1 1 auto")) 39 | remove_button.on_click(lambda _, a=name: remove_feature(a, m)) 40 | display( 41 | widgets.HBox( 42 | [ 43 | widgets.Label(f"{name}:{expression}\n"), 44 | spacer, 45 | remove_button, 46 | ], 47 | layout=widgets.Layout(width="100%"), 48 | ) 49 | ) 50 | 51 | 52 | def run_check_feature_img(feature_img): 53 | """ 54 | Check if a feature image is computable within the timeout. 55 | 56 | Args: 57 | feature_img (ee.Image): The Earth Engine image to check. 58 | 59 | Returns: 60 | bool: True if the image is computable, False otherwise. 61 | """ 62 | try: 63 | _ = feature_img.getInfo() 64 | return True 65 | except Exception as e: 66 | return False 67 | 68 | 69 | @task 70 | def async_add_feature(ds, alias, m, timeout_seconds=10, attempts=3): 71 | """ 72 | Asynchronously add a feature to the map with retry logic. 73 | 74 | Args: 75 | ds (ee.Image): The Earth Engine image to add. 76 | alias (str): The name of the feature. 77 | m (object): The map object to add the feature to. 78 | timeout_seconds (int): The timeout for each attempt in seconds. 79 | attempts (int): The number of attempts to make. 80 | """ 81 | # Add a message 82 | message(m, f"[Background task] Loading `{alias}`...", False) 83 | 84 | pool = Pool(processes=1) # Create a single process pool 85 | 86 | for attempt in range(attempts): 87 | try: 88 | 89 | # Start the process asynchronously using apply_async 90 | result = pool.apply_async(run_check_feature_img, (ds,)) 91 | 92 | # Get the result with a timeout. This will raise a TimeoutError if it exceeds the timeout. 93 | result.get(timeout=timeout_seconds) 94 | 95 | # Visualization task 96 | viz = { 97 | "min": -3.719, 98 | "max": +3.719, 99 | "palette": [ 100 | "#FFFFFF", 101 | "#EEEEEE", 102 | "#CCCCCC", 103 | "#888888", 104 | "#444444", 105 | "#000000", 106 | ], 107 | } 108 | 109 | # Add the layer to the map 110 | m.addLayer(ds, viz, alias) 111 | 112 | message(m, f"[Background task] Loading `{alias}`...", True, 0.1) 113 | 114 | return # Successful, so we exit the function 115 | 116 | except Exception as e: 117 | 118 | # We got a failure, it's simple to just say that it failed and move on to the next attempt. 119 | message(m, f"Attempt {attempt + 1} failed. Retrying...", False) 120 | message(m, f"Attempt {attempt + 1} failed. Retrying...", True, 1) 121 | 122 | # If it's the last attempt, we will not retry 123 | if attempt == attempts - 1: 124 | message(m, f"[Background task] Loading `{alias}`...", True, 0.1) 125 | message(m, f"Consider a lower resolution.", False) 126 | message(m, f"Consider a lower resolution.", True, 1) 127 | return 128 | 129 | else: 130 | # If it's not the last attempt, we will retry 131 | continue 132 | 133 | pool.close() # Close the pool 134 | pool.join() # Ensure all processes have finished 135 | 136 | 137 | def add_feature(e, m, udf_text=None): 138 | """ 139 | Add a user-defined feature to the map. 140 | 141 | This function processes a user-defined function (UDF) expression, 142 | creates a new feature based on the expression, and adds it to the map. 143 | 144 | Args: 145 | e (object): Event object (unused). 146 | m (object): Map object containing various attributes and methods. 147 | udf_text (str, optional): The UDF expression text. If None, it's read from m.udf.value. 148 | 149 | Returns: 150 | None 151 | """ 152 | # Get the UDF expression 153 | udf = m.udf.value if udf_text is None else udf_text 154 | 155 | # If the UDF is empty, we will not add it 156 | if not udf: 157 | message(m, "Please enter a UDF.", False) 158 | message(m, "Please enter a UDF.", True) 159 | return 160 | 161 | if ":" not in udf: 162 | message( 163 | m, 164 | "Please enter a valid UDF expression. Template: `{name}:{expression}`.", 165 | False, 166 | ) 167 | message( 168 | m, 169 | "Please enter a valid UDF expression. Template: `{name}:{expression}`.", 170 | True, 171 | ) 172 | return 173 | 174 | # Get the name and expression 175 | name, expression = udf.replace(" ", "").split(":") 176 | 177 | try: 178 | 179 | # Crop the image to the query region + reference region 180 | qr_roi = m.qr if not m.roi else m.qr.union(m.roi) 181 | 182 | # Stack all the bands into a single image 183 | image = ee.Image.cat([img for _, (_, _, _, _, _, img) in m.aliases.items()]) 184 | 185 | # If the expression is equal to any of the aliases, we will use the alias 186 | if expression in m.aliases: 187 | feature_img = m.aliases[expression][-1] 188 | else: 189 | feature_img = image.expression( 190 | expression, {k: image.select(k) for k in m.aliases.keys()} 191 | ).select(0) 192 | 193 | # Calculate the mean and standard deviation 194 | mean = feature_img.reduceRegion( 195 | reducer=ee.Reducer.mean(), 196 | geometry=qr_roi, 197 | scale=100, 198 | maxPixels=1e3, 199 | bestEffort=True, 200 | tileScale=2, 201 | ) 202 | stdDev = feature_img.reduceRegion( 203 | reducer=ee.Reducer.stdDev(), 204 | geometry=qr_roi, 205 | scale=100, 206 | maxPixels=1e3, 207 | bestEffort=True, 208 | tileScale=2, 209 | ) 210 | 211 | # Verify that the image is computable by calculating the mean. Use a try-except block to stop early if not. 212 | try: 213 | mean.values().getInfo() 214 | except Exception as e: 215 | message(m, f"Error: {e}.", False) 216 | message(m, f"Error: {e}.", True) 217 | return 218 | 219 | # Standardize the feature values using lazy evaluation 220 | mean_image = ee.Image.constant(mean.values()) 221 | stdDev_image = ee.Image.constant(stdDev.values()) 222 | 223 | # Standardize the feature values 224 | feature_img = feature_img.subtract(mean_image).divide(stdDev_image) 225 | 226 | # Add the feature 227 | m.features[name] = [expression, feature_img] 228 | 229 | # Clear the output widget and print the added variables 230 | with m.added_features_output: 231 | m.added_features_output.clear_output() 232 | for name, (expression, _) in m.features.items(): 233 | remove_button = widgets.Button( 234 | description="x", 235 | layout=widgets.Layout( 236 | width="20px", text_align="center", padding="0" 237 | ), 238 | ) 239 | spacer = widgets.HBox([], layout=widgets.Layout(flex="1 1 auto")) 240 | remove_button.on_click(lambda _, a=name: remove_feature(a, m)) 241 | display( 242 | widgets.HBox( 243 | [ 244 | widgets.Label(f"{name}:{expression}\n"), 245 | spacer, 246 | remove_button, 247 | ], 248 | layout=widgets.Layout(width="100%"), 249 | ) 250 | ) 251 | 252 | # Empty out the expression field 253 | m.udf.value = "" 254 | 255 | async_add_feature(feature_img, name, m) 256 | 257 | except Exception as e: 258 | message(m, f"Error: {e}.", False) 259 | message(m, f"Error: {e}.", True, 10) 260 | -------------------------------------------------------------------------------- /region_similarity/helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | """ 4 | This module contains helper functions for working with Google Earth Engine (GEE) objects 5 | and map visualizations. It provides utilities for displaying messages, validating GEE objects, 6 | and updating map visualizations based on thresholds. 7 | """ 8 | 9 | import ee 10 | import time 11 | 12 | 13 | def message(m, text="", clear=True, duration=3): 14 | """ 15 | Displays a message in the output widget of the map object. 16 | 17 | Args: 18 | m (object): Map object containing the output widget. 19 | text (str): The message to display. 20 | clear (bool): Whether to clear the output widget after displaying the message. 21 | duration (int): The duration in seconds to wait before clearing the message. 22 | 23 | Returns: 24 | None 25 | """ 26 | with m.output_widget: 27 | if clear: 28 | time.sleep(duration) 29 | m.output_widget.outputs = tuple( 30 | [e for e in m.output_widget.outputs if e["text"] != f"{text}\n"] 31 | ) 32 | elif text: 33 | m.output_widget.append_stdout(f"{text}\n") 34 | 35 | 36 | def is_valid_gee_object(m, object): 37 | """ 38 | Checks if a given Google Earth Engine (GEE) object is valid by performing light sanity checks based on object type. 39 | If it's valid, return `True`, if it's invalid, share the error message and return False so that parent functions gracefully stop execution. 40 | 41 | Args: 42 | m (object): Map object containing various attributes and methods. 43 | object (ee.ComputedObject): The GEE object to check. 44 | 45 | Returns: 46 | bool: True if the object is valid, False otherwise. 47 | 48 | Raises: 49 | ee.EEException: If there's an error while checking the object. 50 | """ 51 | if isinstance(object, ee.ImageCollection): 52 | # Check if the ImageCollection contains more than 1 image 53 | count = object.size().getInfo() 54 | if count > 0: 55 | return True 56 | else: 57 | message(m, "Error: ImageCollection contains no images.", False) 58 | message(m, "Error: ImageCollection contains no images.", True, 5) 59 | return False 60 | 61 | if isinstance(object, ee.Image): 62 | # Check if the image has bands 63 | bands = object.bandNames().getInfo() 64 | if bands: 65 | return True 66 | else: 67 | message(m, "Image has no bands.", False) 68 | message(m, "Image has no bands.", True, 5) 69 | return False 70 | 71 | elif isinstance(object, ee.Geometry): 72 | # Check if the geometry area is more than 0 73 | area = object.area().getInfo() 74 | if area > 0: 75 | return True 76 | else: 77 | message(m, "Error: Geometry area is 0.", False) 78 | message(m, "Error: Geometry area is 0.", True, 5) 79 | return False 80 | 81 | elif isinstance(object, ee.FeatureCollection): 82 | # Check if the FeatureCollection contains more than 1 feature 83 | count = object.size().getInfo() 84 | if count > 0: 85 | return True 86 | else: 87 | message(m, "Error: FeatureCollection contains no features.", False) 88 | message(m, "Error: FeatureCollection contains no features.", True, 5) 89 | return False 90 | 91 | elif isinstance(object, ee.Feature): 92 | # Check if the feature has valid geometry 93 | area = object.geometry().area().getInfo() 94 | if area > 0: 95 | return True 96 | else: 97 | message(m, "Error: Feature has invalid geometry.", False) 98 | message(m, "Error: Feature has invalid geometry.", True, 5) 99 | return False 100 | 101 | else: 102 | # For other types, use a generic check 103 | try: 104 | _ = object.getInfo() 105 | return True 106 | except ee.EEException as e: 107 | message(m, f"Error: {e}", False) 108 | message(m, f"Error: {e}", True, 5) 109 | return False 110 | 111 | 112 | def update_map(change, m): 113 | """ 114 | Updates the map visualization by applying a threshold to the average distance image. 115 | 116 | This function creates a binary image based on the new threshold value and adds it 117 | as a layer to the map with specified visualization parameters. 118 | 119 | Args: 120 | change (dict): Dictionary containing the new threshold value. 121 | m (object): Map object containing various attributes and methods, including 122 | the average_distance image. 123 | 124 | Returns: 125 | None 126 | 127 | Note: 128 | This function assumes that m.average_distance is a valid ee.Image object. 129 | """ 130 | max_val = change.new 131 | 132 | if m.average_distance != None: 133 | 134 | # Create a binary image with `1==similar` and `0==dissimilar` 135 | binary_image = m.average_distance.lte(max_val) 136 | 137 | # Visualization parameters for the masked image as binary mask 138 | viz_params = {"palette": ["00000000", "FF00007F"], "opacity": 0.5} 139 | 140 | # Add the binary image layer to the map with the specified visualization parameters 141 | m.addLayer(binary_image, viz_params, "Average Distance") 142 | -------------------------------------------------------------------------------- /region_similarity/map.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | """ 4 | This module contains utility functions for managing and updating a map object 5 | in a region similarity analysis application. It includes functions for updating 6 | dropdowns, handling clustering changes, and resetting the map. 7 | """ 8 | 9 | from datetime import date 10 | from region_similarity.helpers import message 11 | 12 | 13 | def update_distance_dropdown(change, m): 14 | """ 15 | Updates the distance function dropdown options based on the selected option. 16 | 17 | Args: 18 | change (dict): Dictionary containing the new category value. 19 | m (object): Map object containing various attributes and methods. 20 | 21 | Returns: 22 | None 23 | """ 24 | try: 25 | m.distance_fun = change["new"] 26 | except Exception as e: 27 | message(m, f"Error: {e}", False) 28 | message(m, f"Error: {e}", True, 5) 29 | 30 | 31 | def update_mask_dropdown(change, m): 32 | """ 33 | Updates the mask dropdown value in the map object. 34 | 35 | Args: 36 | change (dict): Dictionary containing the new value for the mask dropdown. 37 | m (object): Map object containing various attributes and methods. 38 | 39 | Returns: 40 | None 41 | """ 42 | try: 43 | m.mask = change["new"] 44 | except Exception as e: 45 | message(m, f"Error: {e}", False) 46 | message(m, f"Error: {e}", True, 5) 47 | 48 | 49 | def handle_clustering_change(change, m): 50 | """ 51 | Handles changes in the clustering checkbox, updating various UI elements 52 | and map object attributes accordingly. 53 | 54 | Args: 55 | change (dict): Dictionary containing the new value for the clustering checkbox. 56 | m (object): Map object containing various attributes and methods. 57 | 58 | Returns: 59 | None 60 | """ 61 | try: 62 | if change["new"]: 63 | m.cluster = True 64 | m.search_button.description = "Cluster!" 65 | m.set_roi_button.disabled = True 66 | m.roi_upload_button.disabled = True 67 | m.max_value_slider.disabled = True 68 | m.num_clusters.disabled = False 69 | else: 70 | m.cluster = False 71 | m.search_button.description = "Search!" 72 | m.set_roi_button.disabled = False 73 | m.roi_upload_button.disabled = False 74 | m.max_value_slider.disabled = False 75 | m.num_clusters.disabled = True 76 | except Exception as e: 77 | message(m, f"Error: {e}", False) 78 | message(m, f"Error: {e}", True, 5) 79 | 80 | 81 | def reset_map(e, m): 82 | """ 83 | Resets the map object to its initial state, clearing all layers, outputs, 84 | and resetting various attributes and UI elements. 85 | 86 | Args: 87 | e (object): Event object (unused). 88 | m (object): Map object containing various attributes and methods. 89 | 90 | Returns: 91 | None 92 | """ 93 | try: 94 | 95 | # Reset the layers 96 | m.layers = [layer for layer in m.layers if "OpenStreetMap" in layer.name] 97 | 98 | # Clear the output of the added variables widget 99 | with m.added_variables_output: 100 | m.added_variables_output.clear_output() 101 | 102 | # Clear the output of the main output widget 103 | with m.added_features_output: 104 | m.added_features_output.clear_output() 105 | 106 | # Clear the messaging output 107 | with m.output_widget: 108 | m.output_widget.clear_output() 109 | 110 | # Reset various attributes of the map object 111 | m.level = 0 112 | m.qr_set = False 113 | m.roi_set = False 114 | m.qr = None 115 | m.roi = None 116 | m.distances = None 117 | m.average_distance = None 118 | m.roi_upload_button.value = tuple() 119 | m.ros_upload_button.value = tuple() 120 | m.band_dropdown.value = None 121 | m.band_dropdown.options = list() 122 | m.mask_dropdown.value = "All" 123 | update_mask_dropdown({"new": "All"}, m) 124 | m.distance_dropdown.value = "Euclidean" 125 | update_distance_dropdown({"new": "Euclidean"}, m) 126 | m.custom_product_input.value = "" 127 | m.agg_fun_dropdown.value = "LAST" 128 | m.layer_alias_input.value = "" 129 | m.udf.value = "" 130 | m.aliases = dict() 131 | m.features = dict() 132 | handle_clustering_change({"new": False}, m) 133 | m.cluster_checkbox.value = False 134 | m.set_region_button.disabled = False 135 | m.start_date.value = date(2000, 1, 1) 136 | m.end_date.value = date(2000, 1, 1) 137 | m.start = date(2000, 1, 1) 138 | m.end = date(2000, 1, 1) 139 | m.max_value_slider.value = 3.3 140 | m.download_bar.value = 0 141 | m.download_bar.layout.visibility = "hidden" 142 | m.download_bar.layout.height = "0px" 143 | m.spec_import_button.value = tuple() 144 | 145 | except Exception as e: 146 | message(m, f"Error resetting map: {e}", False) 147 | message(m, f"Error resetting map: {e}", True, 5) 148 | -------------------------------------------------------------------------------- /region_similarity/periods.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | """ 4 | This module contains functions for updating the start and end dates of a period of interest in a map object. 5 | It provides error handling and user feedback through message displays. 6 | """ 7 | 8 | from region_similarity.helpers import message 9 | 10 | 11 | def update_start_date(change, m): 12 | """ 13 | Updates the start date of the period of interest in the map object. 14 | 15 | Args: 16 | change (dict): Dictionary containing the new value for the start date. 17 | Expected to have a 'new' key with the updated date value. 18 | m (object): Map object containing various attributes and methods. 19 | Expected to have a 'start' attribute that can be updated. 20 | 21 | Returns: 22 | None 23 | 24 | Raises: 25 | Exception: If there's an error updating the start date, it's caught and displayed as a message. 26 | """ 27 | try: 28 | m.start = change["new"] 29 | except Exception as e: 30 | message(m, f"Error: {e}", False) 31 | message(m, f"Error: {e}", True, 5) 32 | 33 | 34 | def update_end_date(change, m): 35 | """ 36 | Updates the end date of the period of interest in the map object. 37 | 38 | Args: 39 | change (dict): Dictionary containing the new value for the end date. 40 | Expected to have a 'new' key with the updated date value. 41 | m (object): Map object containing various attributes and methods. 42 | Expected to have an 'end' attribute that can be updated. 43 | 44 | Returns: 45 | None 46 | 47 | Raises: 48 | Exception: If there's an error updating the end date, it's caught and displayed as a message. 49 | """ 50 | try: 51 | m.end = change["new"] 52 | except Exception as e: 53 | message(m, f"Error: {e}", False) 54 | message(m, f"Error: {e}", True, 5) 55 | -------------------------------------------------------------------------------- /region_similarity/regions.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | """ 4 | This module contains functions for handling reference region (ROI) and query region (QR) operations. 5 | 6 | It provides functionality for setting regions through drawing on a map or uploading files, 7 | and handles the visualization of these regions on the map. 8 | """ 9 | 10 | from pathlib import Path 11 | from io import BytesIO 12 | import ee 13 | import tempfile 14 | import zipfile 15 | import geopandas as gpd 16 | from shapely.geometry import shape, mapping 17 | from region_similarity.helpers import message 18 | 19 | 20 | def set_region_of_interest(e, m): 21 | """ 22 | Sets the reference region (ROI) based on user-drawn polygons on the map. 23 | 24 | This function processes drawn polygons, converts them to a MultiPolygon geometry, 25 | sets the ROI as an Earth Engine object, and visualizes it on the map. 26 | 27 | Args: 28 | e (object): Event object (unused). 29 | m (object): Map object containing various attributes and methods. 30 | 31 | Returns: 32 | None 33 | 34 | Raises: 35 | Exception: If there's an error setting the search region. 36 | """ 37 | 38 | try: 39 | 40 | if len(m.draw_control.data) == 0: 41 | message(m, "Please draw a reference region on the map.", False) 42 | message(m, "Please draw a reference region on the map.", True) 43 | return 44 | 45 | # Get all drawn geometries 46 | geoms = [ 47 | e["geometry"]["coordinates"] 48 | for e in m.draw_control.data 49 | if e["geometry"]["type"] == "Polygon" 50 | ] 51 | 52 | # Convert to a multipolygon geometry with shapely 53 | roi_geom = shape({"type": "MultiPolygon", "coordinates": geoms}) 54 | 55 | # Set the actual search region as a ee object 56 | m.roi = ee.Geometry(mapping(roi_geom)) 57 | 58 | # Add the new ROI as a GeoDataFrame to the map 59 | m.add_gdf( 60 | gpd.GeoDataFrame(geometry=[roi_geom], crs="EPSG:4326"), 61 | "Reference Region", 62 | style={ 63 | "color": "green", 64 | "fillColor": "#ff0000", 65 | "fillOpacity": 0.01, 66 | "weight": 3, 67 | }, 68 | hover_style={"fillColor": "#ff0000", "fillOpacity": 0}, 69 | info_mode=None, 70 | zoom_to_layer=False, 71 | ) 72 | 73 | # Remove the drawn features 74 | m._draw_control._clear_draw_control() 75 | m.layers = tuple(e for e in m.layers if e.name != "Drawn Features") 76 | 77 | # Set the reference region flag to True 78 | m.roi_set = True 79 | 80 | except Exception as e: 81 | message(m, f"Error setting search region: {e}", False) 82 | message(m, f"Error setting search region: {e}", True, 5) 83 | 84 | 85 | def handle_roi_upload_change(change, m): 86 | """ 87 | Handles the change event for reference region (ROI) file uploads. 88 | 89 | This function processes uploaded files (shapefiles or other supported formats), 90 | sets the ROI based on the file content, and visualizes it on the map. 91 | 92 | Args: 93 | change (dict): Dictionary containing the change information. 94 | m (object): Map object containing various attributes and methods. 95 | 96 | Returns: 97 | None 98 | 99 | Raises: 100 | Exception: If there's an error setting the reference region. 101 | """ 102 | try: 103 | # Get the uploaded file from the ROS upload button 104 | uploaded_file = m.roi_upload_button.value 105 | 106 | if uploaded_file: 107 | file_content = uploaded_file[0]["content"] 108 | file_name = uploaded_file[0]["name"] 109 | 110 | # Extract the files in a temporary directory and read the first *.shp file 111 | if file_name.endswith(".zip"): 112 | with tempfile.TemporaryDirectory() as tmpdir: 113 | with zipfile.ZipFile(BytesIO(file_content), "r") as zip_ref: 114 | zip_ref.extractall(tmpdir) 115 | shp_files = [f for f in Path(tmpdir).rglob("*.shp")] 116 | if shp_files: 117 | gdf = gpd.read_file(shp_files[0]) 118 | else: 119 | # Geopandas can infer the format based on the file extension 120 | gdf = gpd.read_file(BytesIO(file_content)) 121 | 122 | m.roi = ee.Geometry(mapping(gdf.unary_union)) 123 | 124 | # Add the new query region as a GeoDataFrame to the map 125 | m.add_gdf( 126 | gpd.GeoDataFrame( 127 | geometry=gpd.GeoSeries([gdf.unary_union]), crs="EPSG:4326" 128 | ), 129 | "Reference Region", 130 | style={ 131 | "color": "green", 132 | "fillColor": "#ff0000", 133 | "fillOpacity": 0.01, 134 | "weight": 3, 135 | }, 136 | hover_style={"fillColor": "#ff0000", "fillOpacity": 0}, 137 | info_mode=None, 138 | zoom_to_layer=False, 139 | ) 140 | 141 | # Set the reference region flag to True 142 | m.roi_set = True 143 | 144 | except Exception as e: 145 | message(m, f"Error setting reference region: {e}", False) 146 | message(m, f"Error setting reference region: {e}", True, 5) 147 | 148 | 149 | def set_search_region(e, m): 150 | """ 151 | Sets the search region (QR) based on user-drawn polygons on the map. 152 | 153 | This function processes drawn polygons, converts them to a MultiPolygon geometry, 154 | sets the QR as an Earth Engine object, and visualizes it on the map. 155 | 156 | Args: 157 | e (object): Event object (unused). 158 | m (object): Map object containing various attributes and methods. 159 | 160 | Returns: 161 | None 162 | 163 | Raises: 164 | Exception: If there's an error setting the search region. 165 | """ 166 | 167 | try: 168 | 169 | if len(m.draw_control.data) == 0: 170 | message(m, "Please draw a reference region on the map.", False) 171 | message(m, "Please draw a reference region on the map.", True) 172 | return 173 | 174 | # Get all drawn geometries 175 | geoms = [ 176 | e["geometry"]["coordinates"] 177 | for e in m.draw_control.data 178 | if e["geometry"]["type"] == "Polygon" 179 | ] 180 | 181 | # Convert to a multipolygon geometry with shapely 182 | search_geom = shape({"type": "MultiPolygon", "coordinates": geoms}) 183 | 184 | # Set the actual search region as a ee object 185 | m.qr = ee.Geometry(mapping(search_geom)) 186 | 187 | # Add the new ROI as a GeoDataFrame to the map 188 | m.add_gdf( 189 | gpd.GeoDataFrame(geometry=[search_geom], crs="EPSG:4326"), 190 | "Query Region", 191 | style={ 192 | "color": "red", 193 | "fillColor": "#ff0000", 194 | "fillOpacity": 0.01, 195 | "weight": 3, 196 | }, 197 | hover_style={"fillColor": "#ff0000", "fillOpacity": 0}, 198 | info_mode=None, 199 | zoom_to_layer=False, 200 | ) 201 | 202 | # Remove the drawn features 203 | m._draw_control._clear_draw_control() 204 | m.layers = tuple(e for e in m.layers if e.name != "Drawn Features") 205 | 206 | # Set the query region flag to True 207 | m.qr_set = True 208 | 209 | except Exception as e: 210 | message(m, f"Error setting search region: {e}", False) 211 | message(m, f"Error setting search region: {e}", True, 5) 212 | 213 | 214 | def handle_upload_change(change, m): 215 | """ 216 | Handles the change event for region of search (ROS) file uploads. 217 | 218 | This function processes uploaded files (shapefiles or other supported formats), 219 | sets the QR based on the file content, and visualizes it on the map. 220 | 221 | Args: 222 | change (dict): Dictionary containing the change information. 223 | m (object): Map object containing various attributes and methods. 224 | 225 | Returns: 226 | None 227 | 228 | Raises: 229 | Exception: If there's an error setting the search region. 230 | """ 231 | try: 232 | # Get the uploaded file from the ROS upload button 233 | uploaded_file = m.ros_upload_button.value 234 | 235 | if uploaded_file: 236 | file_content = uploaded_file[0]["content"] 237 | file_name = uploaded_file[0]["name"] 238 | 239 | # Extract the files in a temporary directory and read the first *.shp file 240 | if file_name.endswith(".zip"): 241 | with tempfile.TemporaryDirectory() as tmpdir: 242 | with zipfile.ZipFile(BytesIO(file_content), "r") as zip_ref: 243 | zip_ref.extractall(tmpdir) 244 | shp_files = [f for f in Path(tmpdir).rglob("*.shp")] 245 | if shp_files: 246 | gdf = gpd.read_file(shp_files[0]) 247 | else: 248 | # Geopandas can infer the format based on the file extension 249 | gdf = gpd.read_file(BytesIO(file_content)) 250 | 251 | m.qr = ee.Geometry(mapping(gdf.unary_union)) 252 | 253 | # Add the new query region as a GeoDataFrame to the map 254 | m.add_gdf( 255 | gpd.GeoDataFrame( 256 | geometry=gpd.GeoSeries([gdf.unary_union]), crs="EPSG:4326" 257 | ), 258 | "Query Region", 259 | style={ 260 | "color": "red", 261 | "fillColor": "#ff0000", 262 | "fillOpacity": 0.01, 263 | "weight": 3, 264 | }, 265 | hover_style={"fillColor": "#ff0000", "fillOpacity": 0}, 266 | info_mode=None, 267 | zoom_to_layer=False, 268 | ) 269 | 270 | # Set the query region flag to True 271 | m.qr_set = True 272 | 273 | except Exception as e: 274 | message(m, f"Error setting search region: {e}", False) 275 | message(m, f"Error setting search region: {e}", True, 5) 276 | -------------------------------------------------------------------------------- /region_similarity/search.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | """ 4 | This module contains functions for performing region similarity searches and clustering 5 | using Earth Engine imagery. It includes methods for calculating distances between image 6 | pixels and mean vectors, executing searches or clustering operations, and handling map 7 | interactions. 8 | """ 9 | 10 | import matplotlib.pyplot as plt 11 | import matplotlib.colors as mcolors 12 | import ee 13 | import geemap 14 | from shapely.geometry import Point, mapping 15 | from region_similarity.features import add_feature 16 | from region_similarity.helpers import message 17 | from solara.lab import task 18 | 19 | 20 | def calc_distance(map, image, mean_vector, fun): 21 | """ 22 | Calculates the distance between image pixel values and a mean vector using the specified distance function. 23 | 24 | Args: 25 | map (object): Map object for displaying messages. 26 | image (ee.Image): Earth Engine image. 27 | mean_vector (ee.Dictionary): Mean vector of pixel values. 28 | fun (str): Distance function to use. Options are 'Euclidean', 'Manhattan', 'Cosine'. 29 | 30 | Returns: 31 | ee.Image: Image representing the calculated distances. 32 | """ 33 | try: 34 | roi_mean = ee.Image.constant(mean_vector.values()) 35 | 36 | if fun == "Euclidean": 37 | diff = image.subtract(roi_mean) 38 | squared_diff = diff.pow(ee.Number(2)) 39 | distance = squared_diff.reduce("sum").sqrt() 40 | 41 | elif fun == "Manhattan": 42 | diff = image.subtract(roi_mean).abs() 43 | distance = diff.reduce("sum") 44 | 45 | elif fun == "Cosine": 46 | dot_product = image.multiply(roi_mean).reduce("sum") 47 | image_norm = image.pow(2).reduce("sum").sqrt() 48 | mean_norm = roi_mean.pow(2).reduce("sum").sqrt() 49 | cosine_similarity = dot_product.divide(image_norm.multiply(mean_norm)) 50 | distance = ee.Image.constant(1).subtract(cosine_similarity) 51 | 52 | return distance 53 | 54 | except Exception as e: 55 | if map: 56 | message(map, f"Error: {e}.", False) 57 | message(map, f"Error: {e}.", True, 5) 58 | return None 59 | 60 | 61 | def execute(e, m): 62 | """ 63 | Executes when the user clicks on either the `Search` or `Cluster` button. 64 | It ensures features are defined and routes to either `search` or `cluster`. 65 | 66 | Args: 67 | e (object): Event object. 68 | m (object): Map object containing various attributes and methods. 69 | """ 70 | 71 | # If no features are defined, add variable features one by one 72 | if not m.features: 73 | for alias in m.aliases.keys(): 74 | feature_name = alias 75 | feature_expression = alias 76 | udf_text = f"{feature_name}:{feature_expression}" 77 | add_feature(None, m, udf_text) 78 | 79 | # Execute the appropriate function based on the cluster flag 80 | cluster(e, m) if m.cluster else search(e, m) 81 | 82 | 83 | def generate_color_palette(n_clusters): 84 | """ 85 | Generates a color palette for visualizing clusters. 86 | 87 | Args: 88 | n_clusters (int): Number of clusters. 89 | 90 | Returns: 91 | list: List of color hexcodes. 92 | """ 93 | cmap = plt.get_cmap("tab20") 94 | colors = [mcolors.rgb2hex(cmap(i % 20)) for i in range(n_clusters)] 95 | return colors 96 | 97 | 98 | @task 99 | def async_add_clusters(n_clusters, m): 100 | """ 101 | Asynchronously adds clustered image to the map. 102 | 103 | Args: 104 | n_clusters (int): Number of clusters. 105 | m (object): Map object. 106 | """ 107 | 108 | # Display a message 109 | message(m, f"[Background task] Creating {n_clusters} clusters...", False) 110 | 111 | # Define visualization parameters 112 | vis_params = { 113 | "min": 1, 114 | "max": n_clusters, 115 | "palette": generate_color_palette(n_clusters), 116 | } 117 | 118 | # Add the clustered image to the map 119 | m.addLayer(m.clustered, vis_params, "Clusters") 120 | 121 | message(m, f"[Background task] Creating {n_clusters} clusters...", True) 122 | 123 | 124 | @task 125 | def async_add_distance_map(m, region_of_search): 126 | """ 127 | Asynchronously adds distance map to the map and updates slider values. 128 | 129 | Args: 130 | m (object): Map object. 131 | region_of_search (ee.Geometry): Region of search. 132 | """ 133 | 134 | # Display a message 135 | message(m, f"[Background task] Calculating distance maps...", False) 136 | 137 | # Get the minimum and maximum values for the visualization 138 | min_val = m.average_distance.reduceRegion( 139 | reducer=ee.Reducer.min(), 140 | geometry=region_of_search, 141 | scale=100, 142 | bestEffort=True, 143 | ).getInfo()["mean"] 144 | max_val = m.average_distance.reduceRegion( 145 | reducer=ee.Reducer.max(), 146 | geometry=region_of_search, 147 | scale=100, 148 | bestEffort=True, 149 | ).getInfo()["mean"] 150 | 151 | # Update the minimum/maximum values for thresholding 152 | if min_val >= m.max_value_slider.max: 153 | m.max_value_slider.max = max_val 154 | m.max_value_slider.min = min_val 155 | else: 156 | m.max_value_slider.min = min_val 157 | m.max_value_slider.max = max_val 158 | m.max_value_slider.value = (min_val + max_val) / 2 159 | m.max_value_slider.min = min_val - 0.1 * (max_val - min_val) 160 | 161 | m.max_value_slider.min, m.max_value_slider.max = min_val, max_val 162 | 163 | # Add the average distance map to the map 164 | average_viz_params = { 165 | "min": min_val, 166 | "max": max_val, 167 | "palette": ["red", "orange", "yellow", "white", "lightblue", "blue"], 168 | } 169 | m.addLayer(m.average_distance, average_viz_params, "Average Distance") 170 | 171 | message(m, f"[Background task] Calculating distance maps...", True) 172 | 173 | 174 | def cluster(e, m): 175 | """ 176 | Performs clustering on the pixels within the search or query region using KMeans algorithm. 177 | 178 | Args: 179 | e (object): Event object. 180 | m (object): Map object containing various attributes and methods. 181 | """ 182 | 183 | try: 184 | 185 | # Get the number of clusters 186 | n_clusters = m.num_clusters.value 187 | 188 | # Get the distance function 189 | if m.distance_fun == "Euclidean": 190 | distance_fun = "Euclidean" 191 | elif m.distance_fun == "Manhattan": 192 | distance_fun = "Manhattan" 193 | else: 194 | message( 195 | m, 196 | "Warning: Cosine similarity is not supported for clustering. Defaulting to Euclidean distance.", 197 | False, 198 | ) 199 | distance_fun = "Euclidean" 200 | message( 201 | m, 202 | "Warning: Cosine similarity is not supported for clustering. Defaulting to Euclidean distance.", 203 | True, 204 | ) 205 | 206 | # Stack the features 207 | m.feature_img = ee.Image.cat( 208 | [feature_img for _, (_, feature_img) in m.features.items()] 209 | ) 210 | 211 | # Apply dynamic world-masking if specified 212 | if m.mask != "All": 213 | classes = [ 214 | "water", 215 | "trees", 216 | "grass", 217 | "flooded_vegetation", 218 | "crops", 219 | "shrub_and_scrub", 220 | "built", 221 | "bare", 222 | "snow_and_ice", 223 | ] 224 | 225 | # Get the mosaic dynamic world image 226 | landcover = geemap.dynamic_world( 227 | m.qr, 228 | m.start.isoformat(), 229 | m.end.isoformat(), 230 | return_type="class", 231 | ).select("label_mode") 232 | 233 | # Mask the image using the class of interest 234 | m.feature_img = m.feature_img.updateMask( 235 | landcover.eq(classes.index(m.mask)) 236 | ) 237 | 238 | # Create the training dataset 239 | training = m.feature_img.sample( 240 | region=m.qr, 241 | scale=100, 242 | numPixels=5_000, 243 | ) 244 | 245 | # Train the clusterer 246 | clusterer = ee.Clusterer.wekaKMeans( 247 | n_clusters, distanceFunction=distance_fun 248 | ).train(training) 249 | 250 | # Cluster the input features 251 | m.clustered = m.feature_img.cluster(clusterer).add( 252 | 1 253 | ) # `0` is reserved for `nodata` 254 | 255 | # Add the clustered image to the map 256 | async_add_clusters(n_clusters, m) 257 | 258 | except Exception as e: 259 | message(m, f"Error: {e}.", False) 260 | message(map, f"Error: {e}.", True, 5) 261 | 262 | 263 | def search(e, m): 264 | """ 265 | Performs a search operation on the map, calculating distance maps for specified variables. 266 | 267 | Args: 268 | e (object): Event object. 269 | m (object): Map object containing various attributes and methods. 270 | """ 271 | 272 | try: 273 | 274 | # Stack the features 275 | m.feature_img = ee.Image.cat( 276 | [feature_img for _, (_, feature_img) in m.features.items()] 277 | ) 278 | 279 | # Now that we have the aliases and images, we can calculate the distances for each feature 280 | distance_maps = list() 281 | 282 | # Iterate over the features and calculate the distance maps 283 | for _, (_, feature_img) in m.features.items(): 284 | 285 | # Calculate the mean of pixel vectors within the region of interest 286 | roi_mean_vector = feature_img.reduceRegion( 287 | reducer=ee.Reducer.mean(), 288 | geometry=m.roi, 289 | bestEffort=True, 290 | scale=100, 291 | ) 292 | 293 | # Apply the distance calculation function 294 | distance_map = calc_distance( 295 | m, feature_img, roi_mean_vector, m.distance_fun 296 | ) 297 | 298 | # Save the layer to the list 299 | distance_maps.append(distance_map) 300 | 301 | # Stack the distance maps 302 | m.distances = ee.Image.cat(distance_maps) 303 | 304 | # Calculate the average distance across the bands 305 | m.average_distance = m.distances.reduce(ee.Reducer.mean()) 306 | 307 | # Calculate the difference 308 | m.diff = m.qr.difference(right=m.roi, maxError=0.01) 309 | 310 | # Clip it to remove the region of interest 311 | m.average_distance = m.average_distance.clip(m.diff) 312 | 313 | # Apply dynamic world-masking if specified 314 | if m.mask != "All": 315 | classes = [ 316 | "water", 317 | "trees", 318 | "grass", 319 | "flooded_vegetation", 320 | "crops", 321 | "shrub_and_scrub", 322 | "built", 323 | "bare", 324 | "snow_and_ice", 325 | ] 326 | 327 | # Get the mosaic dynamic world image 328 | landcover = geemap.dynamic_world( 329 | m.diff, 330 | m.start.isoformat(), 331 | m.end.isoformat(), 332 | return_type="class", 333 | ).select("label_mode") 334 | 335 | # Mask the image using the class of interest 336 | m.average_distance = m.average_distance.updateMask( 337 | landcover.eq(classes.index(m.mask)) 338 | ) 339 | 340 | # Mask the image using the class of interest 341 | m.feature_img = m.feature_img.updateMask( 342 | landcover.eq(classes.index(m.mask)) 343 | ) 344 | 345 | async_add_distance_map(m, m.qr) 346 | 347 | except Exception as e: 348 | message(m, f"Error: {e}.", False) 349 | message(map, f"Error: {e}.", True, 5) 350 | 351 | 352 | def handle_interaction(m, **kwargs): 353 | """ 354 | Handles user interactions with the map, such as clicking to select regions or display distances. 355 | 356 | Args: 357 | m (object): Map object containing various attributes and methods. 358 | **kwargs: Additional keyword arguments containing interaction details. 359 | """ 360 | 361 | try: 362 | 363 | latlon = kwargs.get("coordinates") 364 | if kwargs.get("type") == "click": 365 | 366 | # Handle click events for displaying similarity percentages 367 | if type(m.distances) == ee.image.Image: 368 | p0, p1 = latlon[1], latlon[0] 369 | p = ee.Geometry(mapping(Point(p0, p1))) 370 | fs = m.distances.sample(p, 1).getInfo()["features"] 371 | click_distances = list(fs[0]["properties"].values()) 372 | scale = 3 373 | percents = [ 374 | str(int(((scale - min(scale, e)) / scale) * 100)) 375 | for e in click_distances 376 | ] 377 | message(m, "% Similarity: " + ", ".join(percents), False) 378 | message(m, "% Similarity: " + ", ".join(percents), True) 379 | 380 | except Exception as e: 381 | message(m, f"Error: {e}.", False) 382 | message(m, f"Error: {e}.", True, 5) 383 | -------------------------------------------------------------------------------- /region_similarity/use_cases.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | """ 4 | This module contains functions for importing and exporting use case spec files for either similarity search or clustering analysis. 5 | It provides functionality to load and save use case configurations, including regions, aliases, features, and other parameters. 6 | """ 7 | 8 | from datetime import datetime, date 9 | import uuid 10 | import yaml 11 | from pathlib import Path 12 | import geopandas as gpd 13 | from solara.lab import task 14 | import ee 15 | from shapely.geometry import shape, mapping 16 | from region_similarity.variables import add_alias 17 | from region_similarity.features import add_feature 18 | from region_similarity.helpers import message 19 | 20 | 21 | @task 22 | def import_spec(m, spec_data): 23 | """ 24 | Import and process use case information for region similarity analysis. 25 | 26 | This function takes a specification in dictionary format and sets up the analysis parameters, 27 | including task type, regions, aliases, features, and other settings. 28 | 29 | Args: 30 | m: The map object to update with the imported specification. 31 | spec_data (dict): The specification data containing task details, regions, aliases, and features. 32 | 33 | Returns: 34 | None. Updates the map object and displays messages about the import process. 35 | """ 36 | # Check for required fields 37 | if ( 38 | "task" not in spec_data 39 | or "aliases" not in spec_data 40 | or not spec_data["aliases"] 41 | ): 42 | message( 43 | m, 44 | "Error: 'task' and at least one 'alias' are required in the spec file.", 45 | False, 46 | ) 47 | message( 48 | m, 49 | "Error: 'task' and at least one 'alias' are required in the spec file.", 50 | True, 51 | ) 52 | return 53 | 54 | task = spec_data["task"] 55 | regions = spec_data.get("regions", {}) 56 | aliases = spec_data["aliases"] 57 | features = spec_data.get("features", []) 58 | distance = spec_data.get("distance") 59 | land_cover = spec_data.get("land_cover") 60 | 61 | # Handle regions 62 | query_region = regions.get("query_region") 63 | if not query_region: 64 | if not m.qr: 65 | message(m, "Error: No query region provided in spec or set on map.", False) 66 | message(m, "Error: No query region provided in spec or set on map.", True) 67 | return 68 | query_region = m.qr.coordinates().getInfo() 69 | 70 | if task == "search": 71 | reference_region = regions.get("reference_region") 72 | if not reference_region: 73 | if not m.roi: 74 | message( 75 | m, 76 | "Error: No reference region provided in spec or set on map for search task.", 77 | False, 78 | ) 79 | message( 80 | m, 81 | "Error: No reference region provided in spec or set on map for search task.", 82 | True, 83 | ) 84 | return 85 | reference_region = m.roi.coordinates().getInfo() 86 | 87 | # Set default period 88 | default_start = m.start_date.value 89 | default_end = m.end_date.value 90 | default_period = ( 91 | None 92 | if default_start == date(2000, 1, 1) and default_end == date(2000, 1, 1) 93 | else (default_start, default_end) 94 | ) 95 | 96 | # Process aliases 97 | processed_aliases = [] 98 | for alias in aliases: 99 | alias_parts = alias.split(":") 100 | if len(alias_parts) != 6: 101 | message(m, f"Error: Invalid alias format: {alias}", False) 102 | message(m, f"Error: Invalid alias format: {alias}", True) 103 | return 104 | 105 | alias_name, product, layer, start, end, agg = alias_parts 106 | 107 | if not product or not layer or not agg: 108 | message(m, f"Error: Incomplete alias specification for {alias_name}", False) 109 | message(m, f"Error: Incomplete alias specification for {alias_name}", True) 110 | return 111 | 112 | if not start or not end: 113 | if default_period is None: 114 | message( 115 | m, 116 | f"Error: Missing period for alias {alias_name} and no default period set.", 117 | False, 118 | ) 119 | message( 120 | m, 121 | f"Error: Missing period for alias {alias_name} and no default period set.", 122 | True, 123 | ) 124 | return 125 | start, end = default_period 126 | else: 127 | start = datetime.strptime(start, "%d/%m/%Y").date() 128 | end = datetime.strptime(end, "%d/%m/%Y").date() 129 | 130 | processed_aliases.append((alias_name, product, layer, start, end, agg)) 131 | 132 | # Set task-specific parameters 133 | if task == "cluster": 134 | m.cluster_checkbox.value = True 135 | m.num_clusters.disabled = False 136 | m.num_clusters.value = regions.get("number_of_clusters", 5) 137 | else: 138 | m.cluster_checkbox.value = False 139 | m.num_clusters.disabled = True 140 | 141 | # Set regions 142 | if query_region: 143 | query_geom = shape({"type": "MultiPolygon", "coordinates": query_region}) 144 | m.qr = ee.Geometry(mapping(query_geom)) 145 | m.add_gdf( 146 | gpd.GeoDataFrame(geometry=[query_geom], crs="EPSG:4326"), 147 | "Query Region", 148 | style={ 149 | "color": "red", 150 | "fillColor": "#ff0000", 151 | "fillOpacity": 0.01, 152 | "weight": 3, 153 | }, 154 | hover_style={"fillColor": "#ff0000", "fillOpacity": 0}, 155 | info_mode=None, 156 | zoom_to_layer=False, 157 | ) 158 | m.qr_set = True 159 | 160 | if task == "search" and reference_region: 161 | reference_geom = shape({"type": "MultiPolygon", "coordinates": reference_region}) 162 | m.roi = ee.Geometry(mapping(reference_geom)) 163 | m.add_gdf( 164 | gpd.GeoDataFrame(geometry=[reference_geom], crs="EPSG:4326"), 165 | "Reference Region", 166 | style={ 167 | "color": "green", 168 | "fillColor": "#ff0000", 169 | "fillOpacity": 0.01, 170 | "weight": 3, 171 | }, 172 | hover_style={"fillColor": "#ff0000", "fillOpacity": 0}, 173 | info_mode=None, 174 | zoom_to_layer=False, 175 | ) 176 | m.roi_set = True 177 | 178 | # Add aliases 179 | for i, (alias_name, product, layer, start, end, agg) in enumerate( 180 | processed_aliases 181 | ): 182 | if i == len(processed_aliases) - 1: 183 | add_alias( 184 | None, m, alias_name, product, layer, start, end, agg, list_aliases=True 185 | ) 186 | else: 187 | add_alias(None, m, alias_name, product, layer, start, end, agg) 188 | 189 | # Add features 190 | for feature in features: 191 | name, expression = feature.split(":") 192 | if expression: 193 | add_feature(None, m, f"{name}:{expression}") 194 | 195 | # Set optional parameters 196 | if distance: 197 | m.distance_dropdown.value = distance 198 | if land_cover: 199 | m.mask_dropdown.value = land_cover 200 | 201 | message(m, "Specification loaded successfully.", False) 202 | message(m, "Specification loaded successfully.", True) 203 | 204 | 205 | def export_spec(e, m): 206 | """ 207 | Export the current region similarity analysis configuration to a YAML file. 208 | 209 | This function collects the current configuration, including aliases, features, regions, 210 | and other parameters, and saves them to a YAML file in the public directory. 211 | 212 | Args: 213 | e: The event object (unused in this function). 214 | m: The map object containing the current analysis configuration. 215 | 216 | Returns: 217 | None. Generates a YAML file and displays a download link message. 218 | """ 219 | # Generate aliases 220 | aliases = list() 221 | for alias_name, (dataset, layer, agg_fun, start, end, _) in m.aliases.items(): 222 | start = ( 223 | datetime.strptime(start, "%Y-%m-%d").date() 224 | if isinstance(start, str) 225 | else start 226 | ) 227 | end = datetime.strptime(end, "%Y-%m-%d").date() if isinstance(end, str) else end 228 | alias_str = f"{alias_name}:{dataset}:{layer}:{start.strftime('%d/%m/%Y')}:{end.strftime('%d/%m/%Y')}:{agg_fun}" 229 | aliases.append(alias_str) 230 | 231 | # If there are no aliases, stop 232 | if not aliases: 233 | message(m, "No aliases found. Please add at least one alias.", False) 234 | message(m, "No aliases found. Please add at least one alias.", True) 235 | return 236 | 237 | # Generate features 238 | features = list() 239 | for name, (expression, _) in m.features.items(): 240 | features.append(f"{name}:{expression}") 241 | 242 | # Get geometries 243 | query_region = m.qr.coordinates().getInfo() if m.qr else [] 244 | reference_region = m.roi.coordinates().getInfo() if m.roi else [] 245 | 246 | # Ensure proper nesting 247 | def ensure_depth(lst, target_depth): 248 | # Recursively ensure the list has the target depth 249 | current_depth = get_depth(lst) 250 | 251 | while current_depth < target_depth: 252 | lst = [lst] # Add a new level of nesting 253 | current_depth += 1 254 | 255 | # If current depth is greater than target, we may need to truncate or adjust the list, 256 | # but for now, we are assuming both lists already fit this condition. 257 | return lst 258 | 259 | def get_depth(lst): 260 | # This function returns the depth of a nested list 261 | if isinstance(lst, list) and lst: 262 | return 1 + max(get_depth(item) for item in lst) 263 | return 0 264 | 265 | query_region = ensure_depth(query_region, 4) 266 | reference_region = ensure_depth(reference_region, 4) 267 | 268 | # If there is no query region, return 269 | if not query_region: 270 | message(m, "No query region found. Please set the query region.", False) 271 | message(m, "No query region found. Please set the query region.", True) 272 | return 273 | 274 | # Determine task 275 | task = "cluster" if m.cluster else "search" 276 | 277 | # Construct spec data 278 | spec_data = { 279 | "aliases": aliases, 280 | "features": features, 281 | "land_cover": m.mask_dropdown.value, 282 | "distance": m.distance_dropdown.value, 283 | "regions": { 284 | "number_of_clusters": m.num_clusters.value, 285 | "query_region": query_region, 286 | "reference_region": reference_region, 287 | }, 288 | "task": task, 289 | } 290 | 291 | # Generate a random hash for the filename 292 | filename = f"{str(uuid.uuid4())}.yaml" 293 | 294 | # Save the file in the public directory 295 | public_dir = Path("./public") 296 | public_dir.mkdir(exist_ok=True) 297 | file_path = public_dir / filename 298 | 299 | # Convert to YAML and save 300 | with open(file_path, "w") as file: 301 | yaml.dump(spec_data, file) 302 | 303 | # Generate download link 304 | download_link = f"{m.url}/static/public/{filename}" 305 | 306 | message(m, f"Download link: {download_link}", False) 307 | message(m, f"Download link: {download_link}", True, duration=5) 308 | -------------------------------------------------------------------------------- /region_similarity/variables.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | """ 4 | This module contains functions for managing user aliases (i.e., GEE variables), 5 | and performing various operations on image collections and layers. 6 | """ 7 | 8 | import ee 9 | import requests 10 | import ipywidgets as widgets 11 | from region_similarity.features import remove_feature 12 | from region_similarity.helpers import message 13 | from solara import display 14 | from solara.lab import task 15 | from multiprocess import Pool 16 | 17 | 18 | def check_image_validity(image): 19 | """ 20 | Check if an Earth Engine image is valid. 21 | 22 | Args: 23 | image (ee.Image): The Earth Engine image to check. 24 | 25 | Returns: 26 | bool: True if the image is valid, False otherwise. 27 | """ 28 | try: 29 | return image.bandNames().size().getInfo() > 0 30 | except Exception as e: 31 | return False 32 | 33 | 34 | def get_bands(product, m): 35 | """ 36 | Retrieve the band names for a given Earth Engine product. 37 | 38 | Args: 39 | product (str): The Earth Engine product ID. 40 | m (object): Map object containing various attributes and methods. 41 | 42 | Returns: 43 | list: A list of band names for the given product. 44 | """ 45 | try: 46 | root = "https://storage.googleapis.com/earthengine-stac/catalog" 47 | pieces = product.split("/") 48 | category = pieces[0] 49 | stac_name = "_".join(pieces) 50 | url = f"{root}/{category}/{stac_name}.json" 51 | response = requests.get(url) 52 | data = response.json() 53 | bands = [band["name"] for band in data["summaries"]["eo:bands"]] 54 | except Exception as e: 55 | try: 56 | dataset = ee.ImageCollection(product).first() 57 | bands = dataset.bandNames().getInfo() 58 | except Exception as e: 59 | try: 60 | dataset = ee.Image(product) 61 | bands = dataset.bandNames().getInfo() 62 | except Exception as e: 63 | message(m, "Error loading bands.", False) 64 | message(m, "Error loading bands.", True, 1) 65 | bands = list() 66 | return bands 67 | 68 | 69 | def update_custom_product(e, m): 70 | """ 71 | Updates the bands dropdown options based on the provided product ID. 72 | 73 | Args: 74 | e (object): Event object (unused). 75 | m (object): Map object containing various attributes and methods. 76 | 77 | Returns: 78 | None 79 | """ 80 | 81 | # Get the product ID 82 | product_id = m.custom_product_input.value 83 | 84 | # If the string is empty, we set the dropdown to an empty list and its default value to None 85 | if not product_id: 86 | m.band_dropdown.options = list() 87 | m.band_dropdown.value = None 88 | return 89 | 90 | # Otherwise, we want to show a `loading` message and look for the product! 91 | message(m, "Loading bands...", False) 92 | 93 | # Get the bands for the product 94 | bands = get_bands(product_id, m) 95 | m.band_dropdown.options = bands 96 | m.band_dropdown.value = bands[0] if bands else None 97 | 98 | # Clear the message 99 | message(m, "Loading bands...", True, 0.1) 100 | 101 | 102 | def remove_alias(alias, m): 103 | """ 104 | Remove an alias from the map and update the UI. 105 | 106 | Args: 107 | alias (str): The alias to remove. 108 | m (object): Map object containing various attributes and methods. 109 | 110 | Returns: 111 | None 112 | """ 113 | 114 | # Remove the alias layer from the map 115 | m.layers = [layer for layer in m.layers if layer.name != alias] 116 | 117 | # Drop the alias from the aliases dictionary 118 | del m.aliases[alias] 119 | 120 | # Clear the output widget and print the added variables 121 | with m.added_variables_output: 122 | m.added_variables_output.clear_output() 123 | for alias, (dataset, layer, agg_fun, _, _, _) in m.aliases.items(): 124 | remove_button = widgets.Button( 125 | description="x", 126 | layout=widgets.Layout(width="20px", text_align="center", padding="0"), 127 | ) 128 | spacer = widgets.HBox([], layout=widgets.Layout(flex="1 1 auto")) 129 | remove_button.on_click(lambda _, a=alias: remove_alias(a, m)) 130 | display( 131 | widgets.HBox( 132 | [ 133 | widgets.Label( 134 | f"{alias}: {dataset.split('/')[-1]}:{layer}:{agg_fun}" 135 | ), 136 | spacer, 137 | remove_button, 138 | ], 139 | layout=widgets.Layout(width="100%"), 140 | ) 141 | ) 142 | 143 | # Find the features that include the alias 144 | features_to_remove = [ 145 | name for name, (expression, _) in m.features.items() if alias in expression 146 | ] 147 | 148 | # Remove the features that include the alias 149 | for feature in features_to_remove: 150 | remove_feature(feature, m) 151 | 152 | 153 | def get_img_minmax(ds, alias, scale=100, tile_scale=1, max_pixels=1e9): 154 | """ 155 | Calculate the minimum and maximum values of an Earth Engine image. 156 | 157 | Args: 158 | ds (ee.Image): The Earth Engine image. 159 | alias (str): The alias for the image band. 160 | scale (int, optional): The scale in meters. Defaults to 100. 161 | tile_scale (int, optional): The tile scale. Defaults to 1. 162 | max_pixels (int, optional): The maximum number of pixels to compute. Defaults to 1e9. 163 | 164 | Returns: 165 | tuple: A tuple containing the minimum and maximum values. 166 | """ 167 | min_max = ds.reduceRegion( 168 | reducer=ee.Reducer.minMax(), 169 | tileScale=tile_scale, 170 | scale=scale, 171 | maxPixels=max_pixels, 172 | bestEffort=True, 173 | ).getInfo() 174 | return min_max[f"{alias}_min"], min_max[f"{alias}_max"] 175 | 176 | 177 | def run_add_alias(ds, alias): 178 | """ 179 | Run the process of adding an alias, including computing min/max values. 180 | 181 | Args: 182 | ds (ee.Image): The Earth Engine image. 183 | alias (str): The alias for the image band. 184 | 185 | Returns: 186 | tuple: A tuple containing the minimum and maximum values. 187 | """ 188 | 189 | # Simulate a long operation to get min/max 190 | min_val, max_val = get_img_minmax(ds=ds, alias=alias) 191 | 192 | # Compute the image 193 | _ = ds.getInfo() 194 | 195 | return min_val, max_val 196 | 197 | 198 | @task 199 | def async_add_alias(ds, alias, m, timeout_seconds=10, attempts=3): 200 | """ 201 | Asynchronously add an alias to the map with retries. 202 | 203 | Args: 204 | ds (ee.Image): The Earth Engine image. 205 | alias (str): The alias for the image band. 206 | m (object): Map object containing various attributes and methods. 207 | timeout_seconds (int, optional): Timeout for each attempt in seconds. Defaults to 10. 208 | attempts (int, optional): Number of attempts to try. Defaults to 3. 209 | 210 | Returns: 211 | None 212 | """ 213 | 214 | # Add a message 215 | message(m, f"[Background task] Loading `{alias}`...", False) 216 | 217 | pool = Pool(processes=1) # Create a single process pool 218 | 219 | for attempt in range(attempts): 220 | try: 221 | 222 | # Start the process asynchronously using apply_async 223 | result = pool.apply_async(run_add_alias, (ds, alias)) 224 | 225 | # Get the result with a timeout. This will raise a TimeoutError if it exceeds the timeout. 226 | min_val, max_val = result.get(timeout=timeout_seconds) 227 | 228 | # Visualization task 229 | viz = { 230 | "min": min_val, 231 | "max": max_val, 232 | "palette": [ 233 | "#FFFFFF", 234 | "#EEEEEE", 235 | "#CCCCCC", 236 | "#888888", 237 | "#444444", 238 | "#000000", 239 | ], 240 | } 241 | 242 | # Add the layer to the map 243 | m.addLayer(ds, viz, alias) 244 | 245 | message(m, f"[Background task] Loading `{alias}`...", True, 0.1) 246 | 247 | return # Successful, so we exit the function 248 | 249 | except Exception as e: 250 | 251 | # We got a failure, it's simple to just say that it failed and move on to the next attempt. 252 | message(m, f"Attempt {attempt + 1} failed. Retrying...", False) 253 | message(m, f"Attempt {attempt + 1} failed. Retrying...", True, 1) 254 | 255 | # If it's the last attempt, we will not retry 256 | if attempt == attempts - 1: 257 | message(m, f"[Background task] Loading `{alias}`...", True, 0.1) 258 | message(m, f"Consider a lower resolution.", False) 259 | message(m, f"Consider a lower resolution.", True, 1) 260 | return 261 | 262 | else: 263 | # If it's not the last attempt, we will retry 264 | continue 265 | 266 | pool.close() # Close the pool 267 | pool.join() # Ensure all processes have finished 268 | 269 | 270 | def add_alias( 271 | e, 272 | m, 273 | alias_name=None, 274 | dataset=None, 275 | layer=None, 276 | start_date=None, 277 | end_date=None, 278 | agg_fun=None, 279 | list_aliases=False, 280 | ): 281 | """ 282 | Adds the selected layer as an alias to the map and updates the UI. 283 | 284 | Args: 285 | e (object): Event object (unused). 286 | m (object): Map object containing various attributes and methods. 287 | alias_name (str, optional): Custom alias name. Defaults to None. 288 | dataset (str, optional): Custom dataset ID. Defaults to None. 289 | layer (str, optional): Custom layer name. Defaults to None. 290 | start_date (datetime, optional): Start date for filtering. Defaults to None. 291 | end_date (datetime, optional): End date for filtering. Defaults to None. 292 | agg_fun (str, optional): Aggregation function. Defaults to None. 293 | list_aliases (bool, optional): Whether to list aliases after adding. Defaults to False. 294 | 295 | Returns: 296 | None 297 | """ 298 | 299 | # Get the selected product ID, layer, and alias 300 | dataset_id = m.custom_product_input.value if dataset is None else dataset 301 | layer_id = m.band_dropdown.value if layer is None else layer 302 | 303 | # If the alias is empty, we will set it to be `{dataset}:{layer}` 304 | alias = m.layer_alias_input.value if alias_name is None else alias_name 305 | if not alias: 306 | alias = f"{agg_fun}({layer_id})" if agg_fun != "NONE" else layer_id 307 | 308 | # If the dataset or layer is empty, we will not add the alias 309 | if not dataset_id or not layer_id: 310 | message(m, "Please select a dataset and layer.", False) 311 | message(m, "Please select a dataset and layer.", True) 312 | return 313 | 314 | # Get the aggregation function 315 | agg_fun = m.agg_fun_dropdown.value if agg_fun is None else agg_fun 316 | 317 | try: 318 | 319 | # Get the start and end dates and format them for querying 320 | start_date = (m.start if start_date is None else start_date).isoformat() 321 | end_date = (m.end if end_date is None else end_date).isoformat() 322 | 323 | # Set the region of interest 324 | qr_roi = m.qr if not m.roi else m.qr.union(m.roi) 325 | 326 | # An attempt to grab an early win if the agg_fun is `LAST` and the user wants an image instead of a collection 327 | ds = None 328 | if agg_fun == "LAST": 329 | img = ee.Image(dataset_id).select(layer_id) 330 | if check_image_validity(img): 331 | ds = img 332 | 333 | # If the user does not want an image, we simply load the collection. 334 | if not ds: 335 | ds = ee.ImageCollection(dataset_id).select(layer_id) 336 | ds = ds.filterDate(start_date, end_date) 337 | ds = ds.filterBounds(qr_roi) 338 | if not ds.size().getInfo(): 339 | message( 340 | m, 341 | "No data available for the selected region and time period.", 342 | False, 343 | ) 344 | message( 345 | m, 346 | "No data available for the selected region and time period.", 347 | True, 348 | ) 349 | m.layer_alias_input.value = "" 350 | return 351 | 352 | # Only do this if `ds` is a collection 353 | if isinstance(ds, ee.ImageCollection): 354 | if agg_fun == "LAST": 355 | ds = ds.sort("system:time_start", False).first() 356 | elif agg_fun == "FIRST": 357 | ds = ds.first() 358 | elif agg_fun == "MAX": 359 | ds = ds.max() 360 | elif agg_fun == "MIN": 361 | ds = ds.min() 362 | elif agg_fun == "MEAN": 363 | ds = ds.mean() 364 | elif agg_fun == "MEDIAN": 365 | ds = ds.median() 366 | elif agg_fun == "SUM": 367 | ds = ds.sum() 368 | elif agg_fun == "MODE": 369 | ds = ds.mode() 370 | else: 371 | message(m, f"aggregation function {agg_fun} is invalid", False) 372 | message(m, f"aggregation function {agg_fun} is invalid", True) 373 | return 374 | 375 | # Clip the image to the query region 376 | ds = ds.clip(qr_roi) 377 | 378 | # Rename the band to the alias 379 | ds = ds.rename(alias) 380 | 381 | # Add the alias to the dictionary 382 | m.aliases[alias] = [dataset_id, layer_id, agg_fun, start_date, end_date, ds] 383 | 384 | # Clear the output widget and print the added variables 385 | if list_aliases: 386 | with m.added_variables_output: 387 | m.added_variables_output.clear_output() 388 | for alias_, (dataset, layer, agg_fun, _, _, _) in m.aliases.items(): 389 | remove_button = widgets.Button( 390 | description="x", 391 | layout=widgets.Layout( 392 | width="20px", text_align="center", padding="0" 393 | ), 394 | ) 395 | spacer = widgets.HBox([], layout=widgets.Layout(flex="1 1 auto")) 396 | remove_button.on_click(lambda _, a=alias_: remove_alias(a, m)) 397 | display( 398 | widgets.HBox( 399 | [ 400 | widgets.Label( 401 | f"{alias_}: {dataset.split('/')[-1]}:{layer}:{agg_fun}" 402 | ), 403 | spacer, 404 | remove_button, 405 | ], 406 | layout=widgets.Layout(width="100%"), 407 | ) 408 | ) 409 | 410 | # Empty the alias field 411 | m.layer_alias_input.value = "" 412 | 413 | # Add the alias to the map as a thread 414 | async_add_alias(ds, alias, m) 415 | 416 | except Exception as e: 417 | message(m, f"Error: {e}.", False) 418 | message(m, f"Error: {e}.", True, 10) 419 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | earthengine-api 2 | geemap 3 | shapely 4 | geopandas 5 | ipywidgets 6 | ipyleaflet 7 | solara 8 | python-dotenv 9 | multiprocess 10 | pyyaml -------------------------------------------------------------------------------- /scripts/app.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | """ 4 | Main application for the Similarity Search Tool. 5 | 6 | This script creates the main Map objects and adds custom GUI widgets to it to enable similarity search and clustering use cases. 7 | """ 8 | 9 | import os 10 | import yaml 11 | from datetime import date 12 | from dotenv import load_dotenv 13 | 14 | load_dotenv() 15 | 16 | import ee 17 | import geemap 18 | import geemap.toolbar 19 | from geemap.toolbar import map_widgets 20 | from geemap.common import search_ee_data, geocode 21 | import ipywidgets as widgets 22 | from ipyleaflet import WidgetControl 23 | import solara 24 | from IPython.core.display import display 25 | 26 | from region_similarity.map import ( 27 | reset_map, 28 | update_mask_dropdown, 29 | update_distance_dropdown, 30 | handle_clustering_change, 31 | ) 32 | from region_similarity.regions import ( 33 | handle_upload_change, 34 | set_search_region, 35 | set_region_of_interest, 36 | handle_roi_upload_change, 37 | ) 38 | from region_similarity.periods import update_end_date, update_start_date 39 | from region_similarity.features import add_feature 40 | from region_similarity.variables import add_alias, update_custom_product 41 | from region_similarity.search import execute, handle_interaction 42 | from region_similarity.export import export_image 43 | from region_similarity.helpers import message, update_map 44 | from region_similarity.use_cases import import_spec, export_spec 45 | 46 | # Define host 47 | hostname = os.environ.get("HOST") 48 | 49 | # Get authentication credentials 50 | google_credentials = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") 51 | 52 | # Authenticate to Earth Engine using GCP project-based authentication 53 | if not google_credentials: 54 | raise ValueError( 55 | "GCP project-based authentication requires both GOOGLE_APPLICATION_CREDENTIALS environment variables to be set" 56 | ) 57 | 58 | try: 59 | credentials = ee.ServiceAccountCredentials("", google_credentials) 60 | ee.Initialize(credentials, opt_url="https://earthengine-highvolume.googleapis.com") 61 | except Exception as e: 62 | raise Exception(f"Failed to authenticate with GCP project credentials: {str(e)}") 63 | 64 | 65 | def ee_data_html(asset): 66 | """ 67 | Generates HTML from an asset to be used in the HTML widget. 68 | 69 | Args: 70 | asset (dict): A dictionary containing an Earth Engine asset. 71 | 72 | Returns: 73 | str: A string containing HTML. 74 | """ 75 | try: 76 | asset_title = asset.get("title", "Unknown") 77 | asset_dates = asset.get("dates", "Unknown") 78 | ee_id_snippet = asset.get("id", "Unknown") 79 | asset_uid = asset.get("uid", None) 80 | asset_url = asset.get("asset_url", "") 81 | code_url = asset.get("sample_code", None) 82 | thumbnail_url = asset.get("thumbnail_url", None) 83 | 84 | if not code_url and asset_uid: 85 | coder_url = f"""https://code.earthengine.google.com/?scriptPath=Examples%3ADatasets%2F{asset_uid}""" 86 | else: 87 | coder_url = code_url 88 | 89 | ## ee datasets always have a asset_url, and should have a thumbnail 90 | catalog = ( 91 | bool(asset_url) 92 | * f""" 93 |

Data Catalog

94 |

Description

95 |

Bands

96 |

Properties

97 |

Example

98 | """ 99 | ) 100 | thumbnail = ( 101 | bool(thumbnail_url) 102 | * f""" 103 |

Dataset Thumbnail

104 | 105 | """ 106 | ) 107 | ## only community datasets have a code_url 108 | alternative = ( 109 | bool(code_url) 110 | * f""" 111 |

Community Catalog

112 |

{asset.get('provider','Provider unknown')}

113 |

{asset.get('tags','Tags unknown')}

114 |

Example

115 | """ 116 | ) 117 | 118 | template = f""" 119 | 120 | 121 |

{asset_title}

122 |

Dataset Availability

123 |

{asset_dates}

124 |

Earth Engine Identifier

125 |

{ee_id_snippet}

126 | {catalog} 127 | {alternative} 128 | {thumbnail} 129 | 130 | 131 | """ 132 | return template 133 | 134 | except Exception as e: 135 | print(e) 136 | 137 | 138 | @map_widgets.Theme.apply 139 | class SearchGEEDataGUI(widgets.VBox): 140 | """ 141 | Custom GUI component for location and data search in GEE. 142 | 143 | This class extends the VBox widget to provide a user interface for searching 144 | both geographical locations and Earth Engine datasets. 145 | """ 146 | 147 | def __init__(self, m, **kwargs): 148 | """ 149 | Initialize the SearchGEEDataGUI. 150 | 151 | Args: 152 | m: The map object to which this GUI is attached. 153 | **kwargs: Additional keyword arguments for the VBox. 154 | """ 155 | # Initialize for both location and data search 156 | m.search_datasets = None 157 | m.search_loc_marker = None 158 | m.search_loc_geom = None 159 | 160 | # Location Search Header (similar to Data Catalog header) 161 | location_header = widgets.HTML( 162 | value=""" 163 |

166 | Location Search 167 |

168 | """ 169 | ) 170 | 171 | # Search location box 172 | search_location_box = widgets.Text( 173 | placeholder="Search by place name or address", 174 | tooltip="Search location", 175 | layout=widgets.Layout(width="400px"), 176 | ) 177 | 178 | def search_location_callback(text): 179 | if text.value != "": 180 | g = geocode(text.value) 181 | if g: 182 | # If a location is found, center the map and zoom in 183 | latlon = (g[0].lat, g[0].lng) 184 | m.search_loc_geom = ee.Geometry.Point(g[0].lng, g[0].lat) 185 | m.center = latlon 186 | m.zoom = 12 187 | text.value = "" 188 | else: 189 | # If no location was found, send a message to the user 190 | message(m, text="No location found. Please try again.", clear=False) 191 | message( 192 | m, 193 | text="No location found. Please try again.", 194 | clear=True, 195 | duration=1, 196 | ) 197 | 198 | search_location_box.on_submit(search_location_callback) 199 | 200 | # Search data catalog box 201 | search_data_box = widgets.Text( 202 | placeholder="Search data catalog by keywords, e.g., `elevation`", 203 | tooltip="Search data", 204 | layout=widgets.Layout(width="400px"), 205 | ) 206 | 207 | search_data_output = widgets.Output( 208 | layout={ 209 | "max_width": "400px", 210 | "max_height": "350px", 211 | "overflow": "scroll", 212 | } 213 | ) 214 | 215 | assets_dropdown = widgets.Dropdown( 216 | options=[], layout=widgets.Layout(min_width="350px", max_width="350px") 217 | ) 218 | 219 | import_btn = widgets.Button( 220 | description="import", 221 | button_style="primary", 222 | tooltip="Click to import the selected asset", 223 | layout=widgets.Layout(min_width="60px", max_width="60px"), 224 | ) 225 | 226 | def import_btn_clicked(b): 227 | if assets_dropdown.value is not None: 228 | datasets = m.search_datasets 229 | dataset = datasets[assets_dropdown.index] 230 | id_ = dataset["id"] 231 | m.accordion.selected_index = 2 232 | m.custom_product_input.value = id_ 233 | search_data_output.clear_output() 234 | search_data_box.value = "" 235 | 236 | import_btn.on_click(import_btn_clicked) 237 | 238 | html_widget = widgets.HTML() 239 | 240 | def dropdown_change(change): 241 | dropdown_index = assets_dropdown.index 242 | if dropdown_index is not None and dropdown_index >= 0: 243 | search_data_output.append_stdout("Loading ...") 244 | datasets = m.search_datasets 245 | dataset = datasets[dropdown_index] 246 | dataset_html = ee_data_html(dataset) # Assuming ee_data_html is defined 247 | html_widget.value = dataset_html 248 | with search_data_output: 249 | search_data_output.clear_output() 250 | display(html_widget) 251 | 252 | assets_dropdown.observe(dropdown_change, names="value") 253 | 254 | def search_data_callback(text): 255 | if text.value != "": 256 | with search_data_output: 257 | print("Searching data catalog ...") 258 | m.default_style = {"cursor": "wait"} 259 | ee_assets = search_ee_data( 260 | text.value, source="all" 261 | ) # Assuming search_ee_data is defined 262 | m.search_datasets = ee_assets 263 | asset_titles = [x["title"] for x in ee_assets] 264 | assets_dropdown.options = asset_titles 265 | if len(ee_assets) > 0: 266 | assets_dropdown.index = 0 267 | html_widget.value = ee_data_html( 268 | ee_assets[0] 269 | ) # Assuming ee_data_html is defined 270 | else: 271 | html_widget.value = "No results found." 272 | with search_data_output: 273 | search_data_output.clear_output() 274 | display(html_widget) 275 | m.default_style = {"cursor": "default"} 276 | else: 277 | search_data_output.clear_output() 278 | assets_dropdown.options = [] 279 | 280 | search_data_box.on_submit(search_data_callback) 281 | assets_combo = widgets.HBox([import_btn, assets_dropdown]) 282 | 283 | # Data Catalog Header 284 | data_header = widgets.HTML( 285 | value=""" 286 |

289 | Data Catalog 290 |

291 | """ 292 | ) 293 | 294 | # Stack the search location and search data catalog widgets 295 | location_search = widgets.VBox([location_header, search_location_box]) 296 | data_search = widgets.VBox( 297 | [data_header, search_data_box, assets_combo, search_data_output] 298 | ) 299 | 300 | # Combine both searches into the parent VBox 301 | super().__init__(children=[location_search, data_search], **kwargs) 302 | 303 | 304 | geemap.toolbar.SearchDataGUI = SearchGEEDataGUI 305 | 306 | 307 | class Map(geemap.Map): 308 | """ 309 | Custom Map class for the Similarity Search Tool. 310 | 311 | This class extends geemap.Map to include additional functionality specific 312 | to the Similarity Search Tool, such as custom widgets and controls. 313 | """ 314 | 315 | def __init__(self, **kwargs): 316 | """ 317 | Initialize the Map object. 318 | 319 | Args: 320 | **kwargs: Additional keyword arguments for geemap.Map. 321 | """ 322 | super().__init__(**kwargs) 323 | self.initialize_map() 324 | self.create_widgets() 325 | self.add_controls() 326 | self.add_layers() 327 | self.initialize_interaction() 328 | 329 | def initialize_map(self): 330 | """Initialize map properties and clear existing layers.""" 331 | self.url = hostname 332 | self.clear_layers() 333 | self.qr_set = False 334 | self.roi_set = False 335 | self.distances = None 336 | self.start = date(2000, 1, 1) 337 | self.end = date(2000, 1, 1) 338 | self.aliases = dict() 339 | self.features = dict() 340 | self.mask = "All" 341 | self.distance_fun = "Euclidean" 342 | self.cluster = False 343 | self.roi = None 344 | self.qr = None 345 | 346 | def create_widgets(self): 347 | """Create and configure all widgets used in the map interface.""" 348 | 349 | self.output_widget = widgets.Output(layout={"border": "1px solid black"}) 350 | self.reset_button = widgets.Button( 351 | description="Reset Map", 352 | layout=widgets.Layout(width="100%", height="30px"), 353 | tooltip="Clear the map and start a new analysis session.", 354 | ) 355 | self.reset_button.on_click(lambda event: reset_map(event, self)) 356 | 357 | self.spec_export_button = widgets.Button( 358 | description="Export Session", 359 | tooltip="Download the current specifications as a YAML file.", 360 | layout=widgets.Layout(width="100%", height="30px"), 361 | ) 362 | 363 | self.spec_export_button.on_click(lambda event: export_spec(event, self)) 364 | 365 | self.spec_import_button = widgets.FileUpload( 366 | description="Import Session", 367 | accept=".yaml", 368 | multiple=False, 369 | tooltip="Upload a specification YAML file to preload parameters.", 370 | layout=widgets.Layout(width="100%", height="30px"), 371 | ) 372 | 373 | def handle_spec_upload(change): 374 | if not change["new"]: 375 | return 376 | content = change["new"][0]["content"] 377 | try: 378 | spec_data = yaml.safe_load(content.decode("utf-8")) 379 | import_spec(self, spec_data) 380 | except yaml.YAMLError as e: 381 | message(self, f"Error parsing YAML file: {str(e)}", False) 382 | message(self, f"Error parsing YAML file: {str(e)}", True) 383 | except Exception as e: 384 | message(self, f"Error importing specification: {str(e)}", False) 385 | message(self, f"Error importing specification: {str(e)}", True) 386 | 387 | self.spec_import_button.observe(handle_spec_upload, names="value") 388 | 389 | self.set_region_button = widgets.Button( 390 | description="Use Drawn Shapes", 391 | layout=widgets.Layout(height="30px"), 392 | tooltip="Click on the map to select a search region. Once done, click here.", 393 | ) 394 | self.set_region_button.on_click(lambda event: set_search_region(event, self)) 395 | self.ros_upload_button = widgets.FileUpload( 396 | description="Upload", 397 | accept=".geojson,.gpkg,.zip", 398 | multiple=False, 399 | tooltip="Upload a GeoJSON, GPKG, or a Shapefile (zipped).", 400 | layout=widgets.Layout(height="30px", width="27%"), 401 | ) 402 | self.ros_upload_button.observe( 403 | lambda event: handle_upload_change(event, self), names="value" 404 | ) 405 | 406 | self.cluster_checkbox = widgets.Checkbox( 407 | description="Cluster Search Region?", 408 | value=False, 409 | indent=False, 410 | layout=widgets.Layout(width="50%"), 411 | tooltip="When checked, the search region will be clustered into groups of similar pixels.", 412 | ) 413 | self.cluster_checkbox.observe( 414 | lambda event: handle_clustering_change(event, self), names="value" 415 | ) 416 | self.num_clusters = widgets.BoundedIntText( 417 | value=3, 418 | min=2, 419 | max=20, 420 | step=1, 421 | description="Number:", 422 | disabled=True, 423 | layout=widgets.Layout(width="50%"), 424 | tooltip="Number of clusters to create in the search region.", 425 | ) 426 | 427 | self.set_roi_button = widgets.Button( 428 | description="Use Drawn Shapes", 429 | layout=widgets.Layout(height="30px"), 430 | tooltip="Use the 'Draw Polygon' tool on the left to draw before clicking here.", 431 | ) 432 | self.set_roi_button.on_click(lambda event: set_region_of_interest(event, self)) 433 | self.roi_upload_button = widgets.FileUpload( 434 | description="Upload", 435 | accept=".geojson,.gpkg,.zip", 436 | multiple=False, 437 | tooltip="Upload a GeoJSON, GPKG, or a Shapefile (zipped).", 438 | layout=widgets.Layout(height="30px", width="27%"), 439 | ) 440 | self.roi_upload_button.observe( 441 | lambda event: handle_roi_upload_change(event, self), names="value" 442 | ) 443 | self.start_date = widgets.DatePicker( 444 | description="Start Date:", 445 | value=date(2000, 1, 1), 446 | tooltip="Start date for the period of interest.", 447 | ) 448 | self.start_date.observe( 449 | lambda event: update_start_date(event, self), names="value" 450 | ) 451 | self.end_date = widgets.DatePicker( 452 | description="End Date:", 453 | value=date(2000, 1, 1), 454 | tooltip="End date for the period of interest.", 455 | ) 456 | self.end_date.observe(lambda event: update_end_date(event, self), names="value") 457 | self.custom_product_input = widgets.Text( 458 | description="Data ID:", 459 | placeholder="e.g. `COPERNICUS/S2_SR_HARMONIZED`", 460 | tooltip="Identifier of the Google Earth Engine dataset to use (search in the top left).", 461 | ) 462 | self.custom_product_input.observe( 463 | lambda event: update_custom_product(event, self), names="value" 464 | ) 465 | self.band_dropdown = widgets.Dropdown( 466 | description="Band:", 467 | placeholder="B4", 468 | ) 469 | self.agg_fun_dropdown = widgets.Dropdown( 470 | options=["LAST", "FIRST", "MAX", "MIN", "MEAN", "MEDIAN", "SUM", "MODE"], 471 | description="Aggregation:", 472 | tooltip="Select the aggregation function to apply to the data.", 473 | ) 474 | self.layer_alias_input = widgets.Text( 475 | description="Alias:", 476 | tooltip="Enter a name for the variable.", 477 | placeholder="e.g. `red`", 478 | ) 479 | self.add_button = widgets.Button( 480 | description="Create Alias", 481 | layout=widgets.Layout(width="100%"), 482 | tooltip="Add the selected variable to the list of aliases.", 483 | ) 484 | self.added_variables_output = widgets.Output( 485 | layout=widgets.Layout(width="100%") 486 | ) 487 | self.add_button.on_click( 488 | lambda event: add_alias(event, self, list_aliases=True) 489 | ) 490 | self.udf = widgets.Text( 491 | description="Expression:", 492 | tooltip="Enter a custom expression to apply.", 493 | placeholder="e.g. `ndvi:(b8-b4)/(b8+b4)`", 494 | ) 495 | self.add_feature = widgets.Button( 496 | description="Add Feature!", 497 | layout=widgets.Layout(width="100%"), 498 | tooltip="Add the custom feature to the list of features.", 499 | ) 500 | self.added_features_output = widgets.Output(layout=widgets.Layout(width="100%")) 501 | self.add_feature.on_click(lambda event: add_feature(event, self)) 502 | self.mask_dropdown = widgets.Dropdown( 503 | options=[ 504 | "All", 505 | "water", 506 | "trees", 507 | "grass", 508 | "flooded_vegetation", 509 | "crops", 510 | "shrub_and_scrub", 511 | "built", 512 | "bare", 513 | "snow_and_ice", 514 | ], 515 | description="Land cover:", 516 | tooltip="Select the land cover mask to apply to the data.", 517 | ) 518 | self.mask_dropdown.observe( 519 | lambda event: update_mask_dropdown(event, self), names="value" 520 | ) 521 | self.max_value_slider = widgets.FloatSlider( 522 | value=3.3, 523 | min=0.33, 524 | max=3.3, 525 | step=0.01, 526 | description="Threshold:", 527 | continuous_update=False, 528 | tooltip="Set the threshold value for the similarity map.", 529 | ) 530 | self.max_value_slider.observe( 531 | lambda event: update_map(event, self), names="value" 532 | ) 533 | self.distance_dropdown = widgets.Dropdown( 534 | options=["Euclidean", "Manhattan", "Cosine"], 535 | description="Distance:", 536 | tooltip="Distance function to use for similarity search or clustering.", 537 | ) 538 | self.distance_dropdown.observe( 539 | lambda event: update_distance_dropdown(event, self), names="value" 540 | ) 541 | self.search_button = widgets.Button( 542 | description="Search!", 543 | layout=widgets.Layout(width="100%", height="40px"), 544 | tooltip="Generate the results map.", 545 | ) 546 | self.search_button.style.font_weight = "bold" 547 | self.search_button.style.font_size = "16px" 548 | self.search_button.on_click(lambda event: execute(event, self)) 549 | self.export_button = widgets.Button( 550 | description="Download", 551 | layout=widgets.Layout(width="100%"), 552 | tooltip="Download the generated map and features.", 553 | ) 554 | self.export_button.on_click(lambda event: export_image(event, self)) 555 | 556 | # Accordion Sections 557 | set_periods = widgets.VBox([self.start_date, self.end_date]) 558 | set_regions = widgets.VBox( 559 | [ 560 | widgets.HBox( 561 | [ 562 | widgets.HTML("Search Region:"), 563 | self.ros_upload_button, 564 | self.set_region_button, 565 | ] 566 | ), 567 | widgets.HBox([self.cluster_checkbox, self.num_clusters]), 568 | widgets.HBox( 569 | [ 570 | widgets.HTML("Control Region:"), 571 | self.roi_upload_button, 572 | self.set_roi_button, 573 | ] 574 | ), 575 | ] 576 | ) 577 | set_aliases = widgets.VBox( 578 | [ 579 | self.custom_product_input, 580 | self.band_dropdown, 581 | self.agg_fun_dropdown, 582 | self.layer_alias_input, 583 | self.add_button, 584 | self.added_variables_output, 585 | ] 586 | ) 587 | set_features = widgets.VBox( 588 | [self.udf, self.add_feature, self.added_features_output] 589 | ) 590 | optional = widgets.VBox([self.mask_dropdown, self.distance_dropdown]) 591 | 592 | self.accordion = widgets.Accordion( 593 | children=[ 594 | set_regions, 595 | set_periods, 596 | set_aliases, 597 | set_features, 598 | optional, 599 | ] 600 | ) 601 | self.accordion.set_title(0, "Step 1: Set Regions") 602 | self.accordion.set_title(1, "Step 2: Set Period") 603 | self.accordion.set_title(2, "Set Variables") 604 | self.accordion.set_title(3, "Set Features") 605 | self.accordion.set_title(4, "Optional") 606 | self.accordion.selected_index = 0 607 | 608 | # Close the layers 609 | toggle_button = self.controls[3].widget.children[0].children[0] 610 | toggle_button.value = not toggle_button.value 611 | 612 | def add_controls(self): 613 | """Add control widgets to the map.""" 614 | 615 | self.output_control = WidgetControl( 616 | widget=self.output_widget, position="bottomleft" 617 | ) 618 | self.add_control(self.output_control) 619 | 620 | controls_vbox = widgets.VBox( 621 | [ 622 | self.accordion, 623 | self.search_button, 624 | self.max_value_slider, 625 | self.export_button, 626 | self.reset_button, 627 | self.spec_export_button, 628 | self.spec_import_button, 629 | ], 630 | layout=widgets.Layout(padding="10px"), 631 | ) 632 | 633 | self.controls_control = WidgetControl(widget=controls_vbox, position="topright") 634 | self.add_control(self.controls_control) 635 | 636 | # Add branding information 637 | branding_html = widgets.HTML( 638 | value=""" 639 |
644 |
Similarity Search Tool
645 |

CGIAR • Microsoft AI4G

646 |
647 | Backend: GEE. 648 |
649 |
650 | """, 651 | layout=widgets.Layout(width="250px"), 652 | ) 653 | self.branding_control = WidgetControl(widget=branding_html, position="topright") 654 | self.add_control(self.branding_control) 655 | 656 | def add_layers(self): 657 | """Add base map layers to the map.""" 658 | self.add_basemap("OpenStreetMap") 659 | 660 | def initialize_interaction(self): 661 | """Set up map interaction handling.""" 662 | self.on_interaction(lambda **kwargs: handle_interaction(self, **kwargs)) 663 | 664 | 665 | @solara.component 666 | def Page(): 667 | """ 668 | Main component for rendering the Similarity Search Tool page. 669 | 670 | This function sets up the layout and includes the Map component along with 671 | necessary CSS styling. 672 | """ 673 | 674 | # Added CSS 675 | css = widgets.HTML( 676 | """ 677 | 693 | """ 694 | ) 695 | 696 | # Pass the appropriate spec data to the Map component 697 | m = Map( 698 | height="100%", 699 | width="100%", 700 | data_ctrl=True, 701 | search_ctrl=False, 702 | scale_ctrl=False, 703 | measure_ctrl=False, 704 | fullscreen_ctrl=False, 705 | toolbar_ctrl=True, 706 | layer_ctrl=True, 707 | attribution_ctrl=False, 708 | zoom=3, 709 | center=(1.6508, 17.7576), 710 | ) 711 | 712 | layout = widgets.VBox( 713 | [css, m], 714 | layout=widgets.Layout(width="100%", height="100vh"), 715 | ) 716 | solara.display(layout) 717 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.1.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:region_similarity/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [bdist_wheel] 15 | universal = 1 16 | 17 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """The setup script.""" 4 | 5 | from setuptools import setup, find_packages 6 | 7 | with open("README.md") as readme_file: 8 | readme = readme_file.read() 9 | 10 | requirements = [] 11 | 12 | test_requirements = [] 13 | 14 | setup( 15 | author="Akram Zaytar", 16 | author_email="akramzaytar@microsoft.com", 17 | python_requires=">=3.6", 18 | classifiers=[ 19 | "Development Status :: 2 - Pre-Alpha", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "Natural Language :: English", 23 | "Programming Language :: Python :: 3" 24 | ], 25 | description="An Earth Engine app that finds similar regions wrt a reference region given some spatiotemporal variables.", 26 | entry_points={ 27 | "console_scripts": [ 28 | "region_similarity=region_similarity.scripts.app:main", 29 | ], 30 | }, 31 | install_requires=requirements, 32 | license="MIT license", 33 | long_description=readme, 34 | include_package_data=True, 35 | keywords="region_similarity", 36 | name="region_similarity", 37 | packages=find_packages(include=["region_similarity", "region_similarity.*"]), 38 | url="https://github.com/microsoft/region_similarity", 39 | version="0.1.0", 40 | zip_safe=False, 41 | ) 42 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36, py37, py38, flake8 3 | 4 | [travis] 5 | python = 6 | 3.8: py38 7 | 3.7: py37 8 | 3.6: py36 9 | 10 | [testenv:flake8] 11 | basepython = python 12 | deps = flake8 13 | commands = flake8 region_similarity tests 14 | 15 | [testenv] 16 | setenv = 17 | PYTHONPATH = {toxinidir} 18 | 19 | commands = python setup.py test 20 | --------------------------------------------------------------------------------