├── .coveragerc ├── .gitignore ├── .gitlab-ci.yml ├── .readthedocs.yaml ├── AUTHORS.md ├── LICENSE ├── README.md ├── RELEASE_NOTES.txt ├── doc ├── MISC.md ├── comparison_otb.md ├── custom_theme │ └── main.html ├── doc_requirements.txt ├── examples │ ├── nodata_mean.md │ └── pleiades.md ├── extra.css ├── features.md ├── functions.md ├── gen_ref_pages.py ├── illustrations │ ├── pyotb_define_processing_area_initial.jpg │ ├── pyotb_define_processing_area_process.jpg │ └── pyotb_define_processing_area_result.jpg ├── index.md ├── installation.md ├── interaction.md ├── managing_loggers.md ├── otb_versions.md ├── quickstart.md ├── summarize.md └── troubleshooting.md ├── mkdocs.yml ├── pyotb ├── __init__.py ├── apps.py ├── core.py ├── functions.py └── helpers.py ├── pyproject.toml └── tests ├── __init__.py ├── pipeline_summary.json ├── test_core.py ├── test_pipeline.py └── tests_data.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | pyotb/functions.py 4 | pyotb/helpers.py 5 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | .idea/ 153 | 154 | # VSCode 155 | .vscode 156 | 157 | # Test artifacts 158 | tests/*.tif -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | default: 2 | tags: [stable] 3 | image: registry.forgemia.inra.fr/orfeo-toolbox/otbtf:5.0.0-cpu-dev 4 | interruptible: true 5 | 6 | variables: 7 | PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" 8 | 9 | cache: 10 | key: $CI_COMMIT_REF_SLUG 11 | paths: 12 | - .cache/pip 13 | 14 | workflow: 15 | rules: 16 | - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push" 17 | when: never 18 | - if: $CI_PIPELINE_SOURCE == "merge_request_event" 19 | - if: $CI_COMMIT_TAG 20 | - if: $CI_COMMIT_REF_PROTECTED == "true" 21 | 22 | stages: 23 | - Static Analysis 24 | - Tests 25 | - Documentation 26 | - Ship 27 | 28 | # -------------------------------- Static analysis -------------------------------- 29 | 30 | .static_analysis: 31 | stage: Static Analysis 32 | allow_failure: true 33 | rules: 34 | - changes: 35 | - pyotb/*.py 36 | 37 | pylint: 38 | extends: .static_analysis 39 | script: 40 | - pylint $PWD/pyotb --disable=fixme 41 | 42 | codespell: 43 | extends: .static_analysis 44 | rules: 45 | - changes: 46 | - "**/*.py" 47 | - "**/*.md" 48 | script: 49 | - codespell {pyotb,tests,doc,README.md} 50 | 51 | pydocstyle: 52 | extends: .static_analysis 53 | before_script: 54 | - pip install pydocstyle tomli 55 | script: 56 | - pydocstyle $PWD/pyotb 57 | 58 | # -------------------------------------- Tests -------------------------------------- 59 | test_install: 60 | stage: Tests 61 | only: 62 | - tags 63 | allow_failure: false 64 | script: 65 | - pip install . 66 | 67 | .tests: 68 | stage: Tests 69 | allow_failure: false 70 | rules: 71 | - changes: 72 | - "**/*.py" 73 | - .gitlab-ci.yml 74 | - .coveragerc 75 | before_script: 76 | - pip install . 77 | variables: 78 | SPOT_IMG_URL: https://gitlab.orfeo-toolbox.org/orfeotoolbox/otb/-/raw/develop/Data/Input/SP67_FR_subset_1.tif 79 | PLEIADES_IMG_URL: https://gitlab.orfeo-toolbox.org/orfeotoolbox/otb/-/raw/develop/Data/Baseline/OTB/Images/prTvOrthoRectification_pleiades-1_noDEM.tif 80 | 81 | module_core: 82 | extends: .tests 83 | variables: 84 | OTB_LOGGER_LEVEL: INFO 85 | PYOTB_LOGGER_LEVEL: DEBUG 86 | artifacts: 87 | reports: 88 | junit: test-module-core.xml 89 | coverage_report: 90 | coverage_format: cobertura 91 | path: coverage.xml 92 | coverage: '/TOTAL.*\s+(\d+%)$/' 93 | script: 94 | - curl -fsLI $SPOT_IMG_URL 95 | - curl -fsLI $PLEIADES_IMG_URL 96 | - pytest -vv --junitxml=test-module-core.xml --cov-report xml:coverage.xml tests/test_core.py 97 | 98 | pipeline_permutations: 99 | extends: .tests 100 | variables: 101 | OTB_LOGGER_LEVEL: WARNING 102 | PYOTB_LOGGER_LEVEL: INFO 103 | artifacts: 104 | reports: 105 | junit: test-pipeline-permutations.xml 106 | script: 107 | - curl -fsLI $SPOT_IMG_URL 108 | - pytest -vv --junitxml=test-pipeline-permutations.xml tests/test_pipeline.py 109 | 110 | # -------------------------------------- Docs --------------------------------------- 111 | 112 | docs: 113 | stage: Documentation 114 | needs: [] 115 | image: python:3.12-slim 116 | rules: 117 | - changes: 118 | - "*.md" 119 | - mkdocs.yml 120 | - doc/* 121 | - pyotb/*.py 122 | before_script: 123 | - python -m venv docs_venv 124 | - source docs_venv/bin/activate 125 | - pip install -U pip 126 | - pip install -r doc/doc_requirements.txt 127 | script: 128 | - mkdocs build --site-dir public 129 | artifacts: 130 | paths: 131 | - public 132 | 133 | # -------------------------------------- Ship --------------------------------------- 134 | 135 | pypi: 136 | stage: Ship 137 | # when: manual 138 | only: 139 | - tags 140 | before_script: 141 | - pip install build twine 142 | script: 143 | - python -m build 144 | - python -m twine upload --non-interactive -u __token__ -p $pypi_token dist/* 145 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-20.04 11 | tools: 12 | python: "3.8" 13 | 14 | mkdocs: 15 | configuration: mkdocs.yml 16 | 17 | # Optionally declare the Python requirements required to build your docs 18 | python: 19 | install: 20 | - requirements: doc/doc_requirements.txt -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Project authors 2 | 3 | ## Initial codebase 4 | 5 | * Nicolas NARÇON (INRAE, now ESA) 6 | 7 | ## Current maintainers 8 | 9 | * Rémi CRESSON (INRAE) 10 | * Vincent DELBAR (La TeleScop) 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2021 Nicolas Narçon (INRAE) 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyotb: Orfeo ToolBox for Python 2 | 3 | [![latest release](https://forgemia.inra.fr/orfeo-toolbox/pyotb/-/badges/release.svg)](https://forgemia.inra.fr/orfeo-toolbox/pyotb/-/releases) 4 | [![pipeline status](https://forgemia.inra.fr/orfeo-toolbox/pyotb/badges/develop/pipeline.svg)](https://forgemia.inra.fr/orfeo-toolbox/pyotb/-/commits/develop) 5 | [![coverage report](https://forgemia.inra.fr/orfeo-toolbox/pyotb/badges/develop/coverage.svg)](https://forgemia.inra.fr/orfeo-toolbox/pyotb/-/commits/develop) 6 | [![read the docs status](https://readthedocs.org/projects/pyotb/badge/?version=master)](https://pyotb.readthedocs.io/en/master/) 7 | 8 | **pyotb** wraps the [Orfeo Toolbox](https://www.orfeo-toolbox.org/) in a pythonic, developer friendly 9 | fashion. 10 | 11 | ## Key features 12 | 13 | - Easy use of Orfeo ToolBox (OTB) applications from python 14 | - Simplify common sophisticated I/O features of OTB 15 | - Lazy execution of operations thanks to OTB streaming mechanism 16 | - Interoperable with popular python libraries ([numpy](https://numpy.org/) and 17 | [rasterio](https://rasterio.readthedocs.io/)) 18 | - Extensible 19 | 20 | Documentation hosted at [pyotb.readthedocs.io](https://pyotb.readthedocs.io/). 21 | 22 | ## Example 23 | 24 | Building a simple pipeline with OTB applications 25 | 26 | ```py 27 | import pyotb 28 | 29 | # RigidTransformResample, with input parameters as dict 30 | resampled = pyotb.RigidTransformResample({ 31 | "in": "https://myserver.ia/input.tif", # Note: no /vsicurl/ 32 | "interpolator": "linear", 33 | "transform.type.id.scaley": 0.5, 34 | "transform.type.id.scalex": 0.5 35 | }) 36 | 37 | # OpticalCalibration, with input parameters as args 38 | calib = pyotb.OpticalCalibration(resampled) 39 | 40 | # BandMath, with input parameters as kwargs 41 | ndvi = pyotb.BandMath(calib, exp="ndvi(im1b1, im1b4)") 42 | 43 | # Pythonic slicing 44 | roi = ndvi[20:586, 9:572] 45 | 46 | # Pipeline execution. The actual computation happens here! 47 | roi.write("output.tif", "float") 48 | ``` 49 | -------------------------------------------------------------------------------- /RELEASE_NOTES.txt: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------- 2 | 2.2.0 (May 20, 2025) - Changes since version 2.1.0 3 | 4 | - Use OTBTF 5.0 dev image for testing 5 | - Fix auto env setting with OTB 9 6 | - Drop deprecated funcs and attrs inherited from pyotb 1.5 7 | - Drop module install.py since OTB now offers an script for easy install 8 | 9 | --------------------------------------------------------------------- 10 | 2.1.0 (Oct 9, 2024) - Changes since version 2.0.2 11 | 12 | - Fix memory leak due to circular references to Output objects in list App.outputs 13 | - Breaking change : replaced App.outputs by a tuple of out image keys (App._out_image_keys) 14 | 15 | --------------------------------------------------------------------- 16 | 2.0.2 (Apr 5, 2024) - Changes since version 2.0.1 17 | 18 | - Change docker image for testing to OTBTF 19 | - Fix a bug with parameters of type "field" for vector files 20 | - Fix wrong output parameter key in ImageClassifier and ImageClassifierFromDeepFeatures 21 | 22 | --------------------------------------------------------------------- 23 | 2.0.1 (Dec 18, 2023) - Changes since version 2.0.0 24 | 25 | - Fix a bug when writing outputs in uint8 26 | 27 | --------------------------------------------------------------------- 28 | 2.0.0 (Nov 23, 2023) - Changes since version 1.5.4 29 | 30 | - Major refactoring (see troubleshooting/migration) 31 | - Pythonic extended filenames (can use dict, etc) 32 | - Easy access to image metadata 33 | - CI improvements (tests, coverage, doc, etc) 34 | - Documentation improvement 35 | - Code format 36 | - Allow OTB dotted parameters in kwargs 37 | - Easy access to pixel coordinates 38 | - Add function to transform x,y coordinates into row, col 39 | - Native support of vsicurl inputs 40 | - Fixes and enhancements in `summarize()` 41 | - Fixes in `shape` 42 | - Add typing to function defs to enhance documentation 43 | 44 | --------------------------------------------------------------------- 45 | 1.5.4 (Oct 01, 2022) - Changes since version 1.5.3 46 | 47 | - Fix slicer wrong end of slicing 48 | 49 | --------------------------------------------------------------------- 50 | 1.5.3 (Sep 29, 2022) - Changes since version 1.5.2 51 | 52 | - Add RELEASE_NOTES.txt 53 | - Add pipeline and version badges in README.md 54 | 55 | --------------------------------------------------------------------- 56 | 1.5.2 (Sep 28, 2022) - Changes since version 1.5.0 57 | 58 | - Update CI 59 | - enforce gitflow with a master branch 60 | - ship the release on pypi.org and gitlab pages update after merge on master branch 61 | - Refactor tests with pytest 62 | - API change: 63 | - add `core.otbObject.name` property 64 | - remove `core.otbObject.output_param` property 65 | - Add `summarize()` function to `core.otbObject` + test. This returns a nested dictionary summarizing the otbObject. 66 | - Improve the auto env init. in helpers.py 67 | - Refactor `otbObject` based classes inheritance : 68 | - Before 69 | ```mermaid 70 | classDiagram 71 | otbObject <|-- Output 72 | otbObject <|-- App 73 | otbObject <|-- Input 74 | otbObject <|-- Operation 75 | otbObject <|-- Slicer 76 | ``` 77 | - After 78 | ```mermaid 79 | classDiagram 80 | otbObject <|-- Output 81 | otbObject <|-- App 82 | App <|-- Input 83 | App <|-- Operation 84 | App <|-- Slicer 85 | ``` 86 | 87 | --------------------------------------------------------------------- 88 | 1.5.0 (Aug 11, 2022) - Changes since version 1.4.1 89 | 90 | - add a `to_rasterio` function 91 | - change the shape convention from (width, height, bands) to (height, width, bands) 92 | - every otbObjects now expose app parameters as a property 93 | - removed App's finished property, and func clear() 94 | - replace App's execute argument with frozen=False by default 95 | - removed App's pixel_type init argument since this should be done using write() only 96 | - log (numpy name of) pixel type propagated to outputs, in debug mode 97 | - docstrings (pydocstyle google convention), now we need type hints ! 98 | - make __get_output_parameters_keys() private since it is already exposed by App.output_parameters_keys 99 | - add App's `description` property to return the OTB App GetDocLongDescription (may be we could do the same with the app help ?) 100 | - renamed App parameter `otb_stdout=True` to `quiet=False` 101 | - renamed App parameter propagate_pixel_type to preserve_dtype 102 | - add new otbObject property 'dtype' 103 | 104 | --------------------------------------------------------------------- 105 | 1.4.1 (Jul 5, 2022) - Changes since version 1.4.0 106 | 107 | - Fix a regression (introduced in 1.4.0) for in-memory pipelines for several apps (non-exhaustive list: OpticalCalibration, BandMath, DynamicConvert). 108 | - Internally, we removed PropagateConnectMode(False) that was problematic 109 | - Fix unit tests 110 | - Add some doc about expected failures of pipelines in some situations 111 | 112 | --------------------------------------------------------------------- 113 | 1.4.0 (Jun 14, 2022) - Changes since version 1.3.3 114 | 115 | - Enhanced the documentation 116 | - Better handling of logger 117 | - Some big changes about in-memory connections (pipelines): 118 | - it is now possible to write pipelines without duplicated execution calls, to achieve that you may pass the output filename directly when creating the app, then just trigger the last app with execute() or write() 119 | - App execute argument is now False by default, this affects oneliners, you may need to edit your scripts if they do not use write() or execute() functions (at least once at the end of a pipeline). 120 | 121 | --------------------------------------------------------------------- 122 | 1.3.3 (Apr 1, 2022) - Changes since version 1.3.1 123 | 124 | - it is now possible to access any parameter of an application: 125 | ```python 126 | info = pyotb.ReadImageInfo('my_image.tif', otb_stdout=False) 127 | info.originy 128 | info['gcp.count'] 129 | ``` 130 | - Bug fix: vector outputs were not written when running an application as a oneliner 131 | - OTBTF PatchesExtraction and TensorflowModelTrain now resolve the number of sources or this number can be set on application initialization 132 | 133 | --------------------------------------------------------------------- 134 | 1.3.1 (Mar 2, 2022) - Changes since version 1.3 135 | 136 | - fix warning when using propagate_pixel_type with uint8 137 | - make easier for the user to use the application TensorflowModelServe. For example, if using pyotb.TensorflowModelServe(sources=[source1, source2...]), the number of sources is inferred from the arguments. Also if needed, the number of sources can be specified like this: pyotb.TensorflowModelServe(n_sources=3) 138 | - remove the limitation that pyotb.run_tf_function could only be executed once. Now the following code work: 139 | ```python 140 | import pyotb 141 | 142 | def multiply(x, y): 143 | return x * y 144 | def add(x, y): 145 | return x + y 146 | 147 | multiplied = pyotb.run_tf_function(multiply)('image1.tif', 'image2.tif') 148 | res = pyotb.run_tf_function(add)(multiplied, 'image2.tif') 149 | res.write('output.tif') 150 | 151 | # NB: In this simple example, running several chained `run_tf_function` is not optimal. 152 | # If possible, try to combine all operations into *one* single function. 153 | ``` 154 | 155 | --------------------------------------------------------------------- 156 | 1.3 (Feb 24, 2022) - Changes since version 1.1.1 157 | 158 | - add the ability to propagate pixel types for App, as optional argument propagate_pixel_type. Add propagation as default behavior for Slicer and Input 159 | - add a to_numpy(propagate_pixel_type=False) methods for all pyotb objects. This method is also called by np.asarray() 160 | - fix the order when using slicing for ROI selection. Before, pyotb was following numpy convention, i.e. obj[rows, cols]. Now, pyotb follow the obj[x, y] which is more adequate for geoghraphic selection 161 | - fix the pyotb.get_pixel_type function 162 | 163 | --------------------------------------------------------------------- 164 | 1.1.1 (Feb 17, 2022) - Changes since version 0.1 (Feb 10, 2022) 165 | 166 | Module 167 | 168 | - Encoding declaration 169 | - Add numpy requirement 170 | - Improve readability when possible (alias otbApplication to otb, return early to avoid deep loops or condition tree, etc...) 171 | - Fix bug with subprocess call to list apps, raise different Exceptions, do not try to subprocess if not in interactive mode since it will fail 172 | - Catch errors better, avoid bare Exception, warn user and exit if OTB can't be found 173 | - Logger should log any error message, while providing user with information regarding context 174 | - User can set its own logging.basicConfig, just need to configure it before importing pyotb then declare logger var 175 | - Add script to remove fstrings from python code 176 | 177 | apps.py 178 | 179 | - Edit code apps.py in order to avoid loading every functions and variables into pyotb namespace (use functions and _variable) 180 | - Apps created in apps.py are classes, not just constructor functions 181 | 182 | core.py 183 | 184 | - Add App argument to make OTB stdout go silent 185 | - Allow user to set_parameters after object instantiation 186 | - Allow user to set a custom name property that will be displayed in logs instead of appname bm = BandMath() ; bm.name = "MyBandMath" 187 | 188 | Break large function set_parameters into several, use class private methods 189 | 190 | - App set_parameters does not execute every time it is called (only if output parameter keys where passed to __init__) 191 | - Add App attribute parameters to store kwargs 192 | - Add App finished attribute to control if app has ran with success 193 | - Add App class functions to control workflow : execute, clear(memory, parameters), find_output 194 | 195 | tools.py 196 | 197 | - To store helpers / general functions like find_otb 198 | 199 | -------------------------------------------------------------------------------- /doc/MISC.md: -------------------------------------------------------------------------------- 1 | ## Miscellaneous: Work with images with different footprints / resolutions 2 | 3 | OrfeoToolBox provides a handy `Superimpose` application that enables the 4 | projection of an image into the geometry of another one. 5 | 6 | In pyotb, a function has been created to handle more than 2 images. 7 | 8 | Let's consider the case where we have 3 images with different resolutions and 9 | different footprints : 10 | 11 | ![Images](illustrations/pyotb_define_processing_area_initial.jpg) 12 | 13 | ```python 14 | import pyotb 15 | 16 | # transforming filepaths to pyotb objects 17 | s2_image = pyotb.Input('image_10m.tif') 18 | vhr_image = pyotb.Input('image_60cm.tif') 19 | labels = pyotb.Input('land_cover_2m.tif') 20 | 21 | print(s2_image.shape) # (286, 195, 4) 22 | print(vhr_image.shape) # (2048, 2048, 3) 23 | print(labels.shape) # (1528, 1360, 1) 24 | ``` 25 | 26 | Our goal is to obtain all images at the same footprint, same resolution and same shape. 27 | Let's consider we want the intersection of all footprints and the same resolution as `labels` image. 28 | 29 | ![Goal](illustrations/pyotb_define_processing_area_process.jpg) 30 | 31 | Here is the final result : 32 | ![Result](illustrations/pyotb_define_processing_area_result.jpg) 33 | 34 | The piece of code to achieve this : 35 | 36 | ```python 37 | s2_image, vhr_image, labels = pyotb.define_processing_area( 38 | s2_image, vhr_image, labels, 39 | window_rule='intersection', 40 | pixel_size_rule='same_as_input', 41 | reference_pixel_size_input=labels, 42 | interpolator='bco' 43 | ) 44 | 45 | print(s2_image.shape) # (657, 520, 4) 46 | print(vhr_image.shape) # (657, 520, 3) 47 | print(labels.shape) # (657, 520, 1) 48 | # Then we can do whichever computations with s2_image, vhr_image, labels 49 | ``` 50 | -------------------------------------------------------------------------------- /doc/comparison_otb.md: -------------------------------------------------------------------------------- 1 | ## Comparison between otbApplication and pyotb 2 | 3 | ### Single application execution 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 43 | 62 | 63 |
OTB pyotb
12 | 13 | ```python 14 | import otbApplication as otb 15 | 16 | input_path = 'my_image.tif' 17 | app = otb.Registry.CreateApplication( 18 | 'RigidTransformResample' 19 | ) 20 | app.SetParameterString( 21 | 'in', input_path 22 | ) 23 | app.SetParameterString( 24 | 'interpolator', 'linear' 25 | ) 26 | app.SetParameterFloat( 27 | 'transform.type.id.scalex', 0.5 28 | ) 29 | app.SetParameterFloat( 30 | 'transform.type.id.scaley', 0.5 31 | ) 32 | app.SetParameterString( 33 | 'out', 'output.tif' 34 | ) 35 | app.SetParameterOutputImagePixelType( 36 | 'out', otb.ImagePixelType_uint16 37 | ) 38 | 39 | app.ExecuteAndWriteOutput() 40 | ``` 41 | 42 | 44 | 45 | ```python 46 | import pyotb 47 | 48 | app = pyotb.RigidTransformResample({ 49 | 'in': 'my_image.tif', 50 | 'interpolator': 'linear', 51 | 'transform.type.id.scaley': 0.5, 52 | 'transform.type.id.scalex': 0.5 53 | }) 54 | 55 | app.write( 56 | 'output.tif', 57 | pixel_type='uint16' 58 | ) 59 | ``` 60 | 61 |
64 | 65 | ### In-memory connections 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 130 | 163 | 164 |
OTB pyotb
74 | 75 | ```python 76 | import otbApplication as otb 77 | 78 | app1 = otb.Registry.CreateApplication( 79 | 'RigidTransformResample' 80 | ) 81 | app1.SetParameterString( 82 | 'in', 'my_image.tif' 83 | ) 84 | app1.SetParameterString( 85 | 'interpolator', 'linear' 86 | ) 87 | app1.SetParameterFloat( 88 | 'transform.type.id.scalex', 0.5 89 | ) 90 | app1.SetParameterFloat( 91 | 'transform.type.id.scaley', 0.5 92 | ) 93 | app1.Execute() 94 | 95 | app2 = otb.Registry.CreateApplication( 96 | 'OpticalCalibration' 97 | ) 98 | app2.ConnectImage('in', app1, 'out') 99 | app2.SetParameterString('level', 'toa') 100 | app2.Execute() 101 | 102 | app3 = otb.Registry.CreateApplication( 103 | 'BinaryMorphologicalOperation' 104 | ) 105 | app3.ConnectImage( 106 | 'in', app2, 'out' 107 | ) 108 | app3.SetParameterString( 109 | 'filter', 'dilate' 110 | ) 111 | app3.SetParameterString( 112 | 'structype', 'ball' 113 | ) 114 | app3.SetParameterInt( 115 | 'xradius', 3 116 | ) 117 | app3.SetParameterInt( 118 | 'yradius', 3 119 | ) 120 | app3.SetParameterString( 121 | 'out', 'output.tif' 122 | ) 123 | app3.SetParameterOutputImagePixelType( 124 | 'out', otb.ImagePixelType_uint16 125 | ) 126 | app3.ExecuteAndWriteOutput() 127 | ``` 128 | 129 | 131 | 132 | ```python 133 | import pyotb 134 | 135 | app1 = pyotb.RigidTransformResample({ 136 | 'in': 'my_image.tif', 137 | 'interpolator': 'linear', 138 | 'transform.type.id.scaley': 0.5, 139 | 'transform.type.id.scalex': 0.5 140 | }) 141 | 142 | app2 = pyotb.OpticalCalibration({ 143 | 'in': app1, 144 | 'level': 'toa' 145 | }) 146 | 147 | app3 = pyotb.BinaryMorphologicalOperation({ 148 | 'in': app2, 149 | 'out': 'output.tif', 150 | 'filter': 'dilate', 151 | 'structype': 'ball', 152 | 'xradius': 3, 153 | 'yradius': 3 154 | }) 155 | 156 | app3.write( 157 | 'result.tif', 158 | pixel_type='uint16' 159 | ) 160 | ``` 161 | 162 |
165 | 166 | ### Arithmetic operations 167 | 168 | Every pyotb object supports arithmetic operations, such as addition, 169 | subtraction, comparison... 170 | Consider an example where we want to perform the arithmetic operation 171 | `image1 * image2 - 2*image3`. 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 209 | 229 | 230 |
OTB pyotb
180 | 181 | ```python 182 | import otbApplication as otb 183 | 184 | bmx = otb.Registry.CreateApplication( 185 | 'BandMathX' 186 | ) 187 | bmx.SetParameterStringList( 188 | 'il', 189 | ['im1.tif', 'im2.tif', 'im3.tif'] 190 | ) 191 | exp = ('im1b1*im2b1-2*im3b1; ' 192 | 'im1b2*im2b2-2*im3b2; ' 193 | 'im1b3*im2b3-2*im3b3') 194 | bmx.SetParameterString('exp', exp) 195 | bmx.SetParameterString( 196 | 'out', 197 | 'output.tif' 198 | ) 199 | bmx.SetParameterOutputImagePixelType( 200 | 'out', 201 | otb.ImagePixelType_uint8 202 | ) 203 | bmx.ExecuteAndWriteOutput() 204 | ``` 205 | 206 | Note: code limited to 3-bands images. 207 | 208 | 210 | 211 | ```python 212 | import pyotb 213 | 214 | # filepaths --> pyotb objects 215 | in1 = pyotb.Input('im1.tif') 216 | in2 = pyotb.Input('im2.tif') 217 | in3 = pyotb.Input('im3.tif') 218 | 219 | res = in1 * in2 - 2 * in3 220 | res.write( 221 | 'output.tif', 222 | pixel_type='uint8' 223 | ) 224 | ``` 225 | 226 | Note: works with any number of bands. 227 | 228 |
231 | 232 | ### Slicing 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 288 | 301 | 302 |
OTB pyotb
241 | 242 | 243 | ```python 244 | import otbApplication as otb 245 | 246 | # first 3 channels 247 | app = otb.Registry.CreateApplication( 248 | 'ExtractROI' 249 | ) 250 | app.SetParameterString( 251 | 'in', 'my_image.tif' 252 | ) 253 | app.SetParameterStringList( 254 | 'cl', 255 | ['Channel1', 'Channel2', 'Channel3'] 256 | ) 257 | app.Execute() 258 | 259 | # 1000x1000 roi 260 | app = otb.Registry.CreateApplication( 261 | 'ExtractROI' 262 | ) 263 | app.SetParameterString( 264 | 'in', 'my_image.tif' 265 | ) 266 | app.SetParameterString( 267 | 'mode', 'extent' 268 | ) 269 | app.SetParameterString( 270 | 'mode.extent.unit', 'pxl' 271 | ) 272 | app.SetParameterFloat( 273 | 'mode.extent.ulx', 0 274 | ) 275 | app.SetParameterFloat( 276 | 'mode.extent.uly', 0 277 | ) 278 | app.SetParameterFloat( 279 | 'mode.extent.lrx', 999 280 | ) 281 | app.SetParameterFloat( 282 | 'mode.extent.lry', 999 283 | ) 284 | app.Execute() 285 | ``` 286 | 287 | 289 | 290 | ```python 291 | import pyotb 292 | 293 | # filepath --> pyotb object 294 | inp = pyotb.Input('my_image.tif') 295 | 296 | extracted = inp[:, :, :3] # Bands 1,2,3 297 | extracted = inp[:1000, :1000] # ROI 298 | ``` 299 | 300 |
-------------------------------------------------------------------------------- /doc/custom_theme/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block extrahead %} 4 | 5 | {% endblock %} -------------------------------------------------------------------------------- /doc/doc_requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocstrings 2 | mkdocstrings[crystal,python] 3 | mkdocs-material 4 | mkdocs-gen-files 5 | mkdocs-section-index 6 | mkdocs-literate-nav 7 | mkdocs-mermaid2-plugin 8 | -------------------------------------------------------------------------------- /doc/examples/nodata_mean.md: -------------------------------------------------------------------------------- 1 | ### Compute the mean of several rasters, taking into account NoData 2 | Let's consider we have at disposal 73 NDVI rasters for a year, where clouds 3 | have been masked with NoData (nodata value of -10 000 for example). 4 | 5 | Goal: compute the mean across time (keeping the spatial dimension) of the 6 | NDVIs, excluding cloudy pixels. Piece of code to achieve that: 7 | 8 | ```python 9 | import pyotb 10 | 11 | nodata = -10000 12 | ndvis = [pyotb.Input(path) for path in ndvi_paths] 13 | 14 | # For each pixel location, summing all valid NDVI values 15 | summed = sum([pyotb.where(ndvi != nodata, ndvi, 0) for ndvi in ndvis]) 16 | 17 | # Printing the generated BandMath expression 18 | print(summed.exp) 19 | # this returns a very long exp: 20 | # "0 + ((im1b1 != -10000) ? im1b1 : 0) + ((im2b1 != -10000) ? im2b1 : 0) + ... 21 | # ... + ((im73b1 != -10000) ? im73b1 : 0)" 22 | 23 | # For each pixel location, getting the count of valid pixels 24 | count = sum([pyotb.where(ndvi == nodata, 0, 1) for ndvi in ndvis]) 25 | 26 | mean = summed / count 27 | # BandMath exp of this is very long: 28 | # "(0 + ((im1b1 != -10000) ? im1b1 : 0) + ... 29 | # + ((im73b1 != -10000) ? im73b1 : 0)) / (0 + ((im1b1 == -10000) ? 0 : 1) + ... 30 | # + ((im73b1 == -10000) ? 0 : 1))" 31 | mean.write('ndvi_annual_mean.tif') 32 | ``` 33 | 34 | Note that no actual computation is executed before the last line where the 35 | result is written to disk. 36 | -------------------------------------------------------------------------------- /doc/examples/pleiades.md: -------------------------------------------------------------------------------- 1 | ### Process raw Pleiades data 2 | This is a common case of Pleiades data preprocessing : 3 | *optical calibration -> orthorectification -> pansharpening* 4 | 5 | ```python 6 | import pyotb 7 | srtm = '/media/data/raster/nasa/srtm_30m' 8 | geoid = '/media/data/geoid/egm96.grd' 9 | 10 | pan = pyotb.OpticalCalibration( 11 | 'IMG_PHR1A_P_001/DIM_PHR1A_P_201509011347379_SEN_1791374101-001.XML', 12 | level='toa' 13 | ) 14 | ms = pyotb.OpticalCalibration( 15 | 'IMG_PHR1A_MS_002/DIM_PHR1A_MS_201509011347379_SEN_1791374101-002.XML', 16 | level='toa' 17 | ) 18 | 19 | pan_ortho = pyotb.OrthoRectification({ 20 | 'io.in': pan, 21 | 'elev.dem': srtm, 22 | 'elev.geoid': geoid 23 | }) 24 | ms_ortho = pyotb.OrthoRectification({ 25 | 'io.in': ms, 26 | 'elev.dem': srtm, 27 | 'elev.geoid': geoid 28 | }) 29 | 30 | pxs = pyotb.BundleToPerfectSensor( 31 | inp=pan_ortho, 32 | inxs=ms_ortho, 33 | method='bayes', 34 | mode='default' 35 | ) 36 | 37 | exfn = '?gdal:co:COMPRESS=DEFLATE&gdal:co:PREDICTOR=2&gdal:co:BIGTIFF=YES' 38 | # Here we trigger every app in the pipeline and the process is blocked until 39 | # result is written to disk 40 | pxs.write('pxs_image.tif', pixel_type='uint16', ext_fname=exfn) 41 | ``` 42 | -------------------------------------------------------------------------------- /doc/extra.css: -------------------------------------------------------------------------------- 1 | .rst-content div[class^=highlight] { 2 | border: 0px; 3 | } 4 | 5 | .rst-content div[class^=highlight] pre { 6 | padding: 0px; 7 | } 8 | 9 | .rst-content pre code { 10 | background: #eeffcc; 11 | } 12 | -------------------------------------------------------------------------------- /doc/features.md: -------------------------------------------------------------------------------- 1 | ## Arithmetic operations 2 | 3 | Every pyotb object supports arithmetic operations, such as addition, 4 | subtraction, comparison... 5 | Consider an example where we want to compute a vegeteation mask from NDVI, 6 | i.e. the arithmetic operation `(nir - red) / (nir + red) > 0.3` 7 | 8 | With pyotb, one can simply do : 9 | 10 | ```python 11 | import pyotb 12 | 13 | # transforming filepaths to pyotb objects 14 | nir, red = pyotb.Input('nir.tif'), pyotb.Input('red.tif') 15 | 16 | res = (nir - red) / (nir + red) > 0.3 17 | # Prints the BandMath expression: 18 | # "((im1b1 - im2b1) / (im1b1 + im2b1)) > 0.3 ? 1 : 0" 19 | print(res.exp) 20 | res.write('vegetation_mask.tif', pixel_type='uint8') 21 | ``` 22 | 23 | ## Slicing 24 | 25 | pyotb objects support slicing in a Python fashion : 26 | 27 | ```python 28 | import pyotb 29 | 30 | # transforming filepath to pyotb object 31 | inp = pyotb.Input('my_image.tif') 32 | 33 | inp[:, :, :3] # selecting first 3 bands 34 | inp[:, :, [0, 1, 4]] # selecting bands 1, 2 & 5 35 | inp[:, :, 1:-1] # removing first and last band 36 | inp[:, :, ::2] # selecting one band every 2 bands 37 | inp[:100, :100] # selecting 100x100 subset, same as inp[:100, :100, :] 38 | inp[:100, :100].write('my_image_roi.tif') # write cropped image to disk 39 | ``` 40 | 41 | ## Retrieving a pixel location in image coordinates 42 | 43 | One can retrieve a pixel location in image coordinates (i.e. row and column 44 | indices) using `get_rowcol_from_xy()`: 45 | 46 | ```python 47 | inp.get_rowcol_from_xy(760086.0, 6948092.0) # (333, 5) 48 | ``` 49 | 50 | ## Reading a pixel value 51 | 52 | One can read a pixel value of a pyotb object using brackets, as if it was a 53 | common array. Returned is a list of pixel values for each band: 54 | 55 | ```python 56 | inp[10, 10] # [217, 202, 182, 255] 57 | ``` 58 | 59 | !!! warning 60 | 61 | Accessing multiple pixels values if not computationally efficient. Please 62 | use this with moderation, or consider numpy or pyotb applications to 63 | process efficiently blocks of pixels. 64 | 65 | ## Attributes 66 | 67 | ### Shape 68 | 69 | The shape of pyotb objects can be retrieved using `shape`. 70 | 71 | ```python 72 | print(inp[:1000, :500].shape) # (1000, 500, 4) 73 | ``` 74 | 75 | ### Pixel type 76 | 77 | The pixel type of pyotb objects can be retrieved using `dtype`. 78 | 79 | ```python 80 | inp.dtype # e.g. 'uint8' 81 | ``` 82 | 83 | !!! note 84 | 85 | The `dtype` returns a `str` corresponding to values accepted by the 86 | `pixel_type` of `write()` 87 | 88 | ### Transform 89 | 90 | The transform, as defined in GDAL, can be retrieved with the `transform` 91 | attribute: 92 | 93 | ```python 94 | inp.transform # (6.0, 0.0, 760056.0, 0.0, -6.0, 6946092.0) 95 | ``` 96 | 97 | ### Metadata 98 | 99 | Images metadata can be retrieved with the `metadata` attribute: 100 | 101 | ```python 102 | print(inp.metadata) 103 | ``` 104 | 105 | Gives: 106 | 107 | ``` 108 | { 109 | 'DataType': 1.0, 110 | 'DriverLongName': 'GeoTIFF', 111 | 'DriverShortName': 'GTiff', 112 | 'GeoTransform': (760056.0, 6.0, 0.0, 6946092.0, 0.0, -6.0), 113 | 'LowerLeftCorner': (760056.0, 6944268.0), 114 | 'LowerRightCorner': (761562.0, 6944268.0), 115 | 'AREA_OR_POINT': 'Area', 116 | 'TIFFTAG_SOFTWARE': 'CSinG - 13 SEPTEMBRE 2012', 117 | 'ProjectionRef': 'PROJCS["RGF93 v1 / Lambert-93",\n...', 118 | 'ResolutionFactor': 0, 119 | 'SubDatasetIndex': 0, 120 | 'UpperLeftCorner': (760056.0, 6946092.0), 121 | 'UpperRightCorner': (761562.0, 6946092.0), 122 | 'TileHintX': 251.0, 123 | 'TileHintY': 8.0 124 | } 125 | ``` 126 | 127 | ## Information 128 | 129 | The information fetched by the `ReadImageInfo` OTB application is available 130 | through `get_info()`: 131 | 132 | ```python 133 | print(inp.get_info()) 134 | ``` 135 | 136 | Gives: 137 | 138 | ```json lines 139 | { 140 | 'indexx': 0, 141 | 'indexy': 0, 142 | 'sizex': 251, 143 | 'sizey': 304, 144 | 'spacingx': 6.0, 145 | 'spacingy': -6.0, 146 | 'originx': 760059.0, 147 | 'originy': 6946089.0, 148 | 'estimatedgroundspacingx': 5.978403091430664, 149 | 'estimatedgroundspacingy': 5.996793270111084, 150 | 'numberbands': 4, 151 | 'datatype': 'unsigned_char', 152 | 'ullat': 0.0, 153 | 'ullon': 0.0, 154 | 'urlat': 0.0, 155 | 'urlon': 0.0, 156 | 'lrlat': 0.0, 157 | 'lrlon': 0.0, 158 | 'lllat': 0.0, 159 | 'lllon': 0.0, 160 | 'rgb.r': 0, 161 | 'rgb.g': 1, 162 | 'rgb.b': 2, 163 | 'projectionref': 'PROJCS["RGF93 v1 ..."EPSG","2154"]]', 164 | 'gcp.count': 0 165 | } 166 | ``` 167 | 168 | ## Statistics 169 | 170 | Image statistics can be computed on-the-fly using `get_statistics()`: 171 | 172 | ```python 173 | print(inp.get_statistics()) 174 | ``` 175 | 176 | Gives: 177 | 178 | ```json lines 179 | { 180 | 'out.mean': [79.5505, 109.225, 115.456, 249.349], 181 | 'out.min': [33, 64, 91, 47], 182 | 'out.max': [255, 255, 230, 255], 183 | 'out.std': [51.0754, 35.3152, 23.4514, 20.3827] 184 | } 185 | ``` -------------------------------------------------------------------------------- /doc/functions.md: -------------------------------------------------------------------------------- 1 | Some functions have been written, entirely based on OTB, to mimic the behavior 2 | of some well-known numpy functions. 3 | 4 | ## pyotb.where 5 | 6 | Equivalent of `numpy.where`. 7 | It is the equivalent of the muparser syntax `condition ? x : y` that can be 8 | used in OTB's BandMath. 9 | 10 | ```python 11 | import pyotb 12 | 13 | # transforming filepaths to pyotb objects 14 | labels = pyotb.Input('labels.tif') 15 | image1 = pyotb.Input('image1.tif') 16 | image2 = pyotb.Input('image2.tif') 17 | 18 | # If labels = 1, returns image1. Else, returns image2 19 | res = pyotb.where(labels == 1, image1, image2) 20 | # this would also work: `pyotb.where(labels == 1, 'image1.tif', 'image2.tif')` 21 | 22 | # A more complex example: 23 | # - If labels = 1 --> returns image1, 24 | # - If labels = 2 --> returns image2, 25 | # - If labels = 3 --> returns 3.0, 26 | # - Else, returns 0.0 27 | res = pyotb.where( 28 | labels == 1, 29 | image1, 30 | pyotb.where( 31 | labels == 2, 32 | image2, 33 | pyotb.where( 34 | labels == 3, 35 | 3.0, 36 | 0.0 37 | ) 38 | ) 39 | ) 40 | 41 | ``` 42 | 43 | ## pyotb.clip 44 | 45 | Equivalent of `numpy.clip`. Clip (limit) the values in a raster to a range. 46 | 47 | ```python 48 | import pyotb 49 | 50 | res = pyotb.clip('my_image.tif', 0, 255) # clips the values between 0 and 255 51 | ``` 52 | 53 | ## pyotb.all 54 | 55 | Equivalent of `numpy.all`. 56 | 57 | For only one image, this function checks that all bands of the image are True 58 | (i.e. !=0) and outputs a single band boolean raster. 59 | For several images, this function checks that all images are True (i.e. !=0) 60 | and outputs a boolean raster, with as many bands as the inputs. 61 | 62 | 63 | ## pyotb.any 64 | 65 | Equivalent of `numpy.any`. 66 | 67 | For only one image, this function checks that at least one band of the image 68 | is True (i.e. !=0) and outputs a single band boolean raster. 69 | For several images, this function checks that at least one of the images is 70 | True (i.e. !=0) and outputs a boolean raster, with as many bands as the inputs. -------------------------------------------------------------------------------- /doc/gen_ref_pages.py: -------------------------------------------------------------------------------- 1 | """Generate the code reference pages.""" 2 | 3 | from pathlib import Path 4 | 5 | import mkdocs_gen_files 6 | 7 | for path in sorted(Path("pyotb").rglob("*.py")): # 8 | module_path = path.relative_to(".").with_suffix("") # 9 | doc_path = path.relative_to(".").with_suffix(".md") # 10 | full_doc_path = Path("reference", doc_path) # 11 | 12 | parts = list(module_path.parts) 13 | 14 | if parts[-1] == "__init__": # 15 | parts = parts[:-1] 16 | elif parts[-1] == "__main__": 17 | continue 18 | 19 | with mkdocs_gen_files.open(full_doc_path, "w") as fd: # 20 | identifier = ".".join(parts) # 21 | print("::: " + identifier) 22 | print("::: " + identifier, file=fd) # 23 | 24 | mkdocs_gen_files.set_edit_path(full_doc_path, path) 25 | 26 | 27 | ''' 28 | """Generate the code reference pages and navigation.""" 29 | import shutil 30 | from pathlib import Path 31 | 32 | import mkdocs_gen_files 33 | 34 | nav = mkdocs_gen_files.Nav() 35 | 36 | processed_paths = ["pyotb"] 37 | 38 | paths = [] 39 | for processed_path in processed_paths: 40 | paths += Path(processed_path).rglob("*.py") 41 | 42 | for path in paths: 43 | module_path = path.relative_to(".").with_suffix("") 44 | doc_path = path.relative_to(".").with_suffix(".md") 45 | full_doc_path = Path("reference", doc_path) 46 | print("\n---") 47 | print(f"path: {path}") 48 | print(f"module path: {module_path}") 49 | print(f"doc path:{doc_path}") 50 | 51 | parts = tuple(module_path.parts) 52 | print(f"parts: {parts}") 53 | 54 | if parts[-1] == "__init__": 55 | parts = parts[:-1] 56 | doc_path = doc_path.with_name("index.md") 57 | full_doc_path = full_doc_path.with_name("index.md") 58 | elif parts[-1] == "__main__": 59 | continue 60 | 61 | print(f"new doc path:{doc_path}") 62 | print(f"new parts: {parts}") 63 | print(f"doc part as posix {doc_path.as_posix()}") 64 | try: 65 | nav[(parts[0], parts[1] + '.md')] = doc_path.as_posix() 66 | except Exception as e: 67 | nav[parts] = doc_path.as_posix() 68 | 69 | with mkdocs_gen_files.open(full_doc_path, "w") as fd: 70 | ident = ".".join(parts) 71 | fd.write(f"::: {ident}") 72 | 73 | mkdocs_gen_files.set_edit_path(full_doc_path, path) 74 | 75 | print('#############') 76 | print('#############') 77 | print(nav._data) 78 | print('#############') 79 | print('#############') 80 | with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: 81 | nav_file.writelines(nav.build_literate_nav()) 82 | shutil.copy('reference/SUMMARY.md', '/tmp/SUMMARY.md') 83 | 84 | 85 | with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: 86 | nav_file.writelines(nav.build_literate_nav()) 87 | ''' -------------------------------------------------------------------------------- /doc/illustrations/pyotb_define_processing_area_initial.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orfeotoolbox/pyotb/d7fec19cbc4e61fdb1356c7872faede9aeac8607/doc/illustrations/pyotb_define_processing_area_initial.jpg -------------------------------------------------------------------------------- /doc/illustrations/pyotb_define_processing_area_process.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orfeotoolbox/pyotb/d7fec19cbc4e61fdb1356c7872faede9aeac8607/doc/illustrations/pyotb_define_processing_area_process.jpg -------------------------------------------------------------------------------- /doc/illustrations/pyotb_define_processing_area_result.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orfeotoolbox/pyotb/d7fec19cbc4e61fdb1356c7872faede9aeac8607/doc/illustrations/pyotb_define_processing_area_result.jpg -------------------------------------------------------------------------------- /doc/index.md: -------------------------------------------------------------------------------- 1 | # pyotb: Orfeo Toolbox for Python 2 | 3 | pyotb is a Python extension of Orfeo Toolbox. It has been built on top of the 4 | existing Python API of OTB, in order 5 | to make OTB more Python friendly. 6 | 7 | # Table of Contents 8 | 9 | ## Get started 10 | 11 | - [Installation](installation.md) 12 | - [Quick start](quickstart.md) 13 | - [Useful features](features.md) 14 | - [Functions](functions.md) 15 | - [Interaction with Python libraries (numpy, rasterio, tensorflow)](interaction.md) 16 | 17 | ## Examples 18 | 19 | - [Pleiades data processing](examples/pleiades.md) 20 | - [Computing the mean of several rasters with NoData](examples/nodata_mean.md) 21 | 22 | ## Advanced use 23 | 24 | - [Comparison between pyotb and OTB native library](comparison_otb.md) 25 | - [Summarize applications](summarize.md) 26 | - [OTB versions](otb_versions.md) 27 | - [Managing loggers](managing_loggers.md) 28 | - [Troubleshooting & limitations](troubleshooting.md) 29 | 30 | ## API 31 | 32 | - See the API reference. If you have any doubts or questions, feel free to ask 33 | on github or gitlab! 34 | 35 | ## Contribute 36 | 37 | Contributions are welcome ! 38 | Open a PR/MR, or file an issue if you spot a bug or have any suggestion: 39 | 40 | - [Github](https://github.com/orfeotoolbox/pyotb) 41 | - [Orfeo ToolBox GitLab instance](https://forgemia.inra.fr/orfeo-toolbox/pyotb). 42 | 43 | Thank you! 44 | -------------------------------------------------------------------------------- /doc/installation.md: -------------------------------------------------------------------------------- 1 | ## Prerequisites 2 | 3 | Requirements: 4 | 5 | - Python >= 3.7 and NumPy 6 | - Orfeo ToolBox binaries (follow these 7 | [instructions](https://www.orfeo-toolbox.org/CookBook/Installation.html)) 8 | - Orfeo ToolBox python binding (follow these 9 | [instructions](https://www.orfeo-toolbox.org/CookBook/Installation.html#python-bindings)) 10 | 11 | ## Install with pip 12 | 13 | ```bash 14 | pip install pyotb --upgrade 15 | ``` 16 | 17 | For development, use the following: 18 | 19 | ```bash 20 | git clone https://forgemia.inra.fr/orfeo-toolbox/pyotb 21 | cd pyotb 22 | pip install -e ".[dev]" 23 | ``` 24 | 25 | ## Old versions 26 | 27 | If you need compatibility with python3.6, install `pyotb<2.0` and for 28 | python3.5 use `pyotb==1.2.2`. 29 | -------------------------------------------------------------------------------- /doc/interaction.md: -------------------------------------------------------------------------------- 1 | ## Numpy 2 | 3 | ### Export to numpy arrays 4 | 5 | pyotb objects can be exported to numpy array. 6 | 7 | ```python 8 | import pyotb 9 | import numpy as np 10 | 11 | # The following is a pyotb object 12 | calibrated = pyotb.OpticalCalibration('image.tif', level='toa') 13 | 14 | # The following is a numpy array 15 | arr = np.asarray(calibrated) 16 | 17 | # Note that the following is equivalent: 18 | arr = calibrated.to_numpy() 19 | ``` 20 | 21 | ### Interact with numpy functions 22 | 23 | pyotb objects can be transparently used in numpy functions. 24 | 25 | For example: 26 | 27 | ```python 28 | import pyotb 29 | import numpy as np 30 | 31 | # The following is a pyotb object 32 | inp = pyotb.Input('image.tif') 33 | 34 | # Creating a numpy array of noise. The following is a numpy object 35 | white_noise = np.random.normal(0, 50, size=inp.shape) 36 | 37 | # Adding the noise to the image 38 | noisy_image = inp + white_noise 39 | # Magically, this is a pyotb object that has the same geo-reference as `inp`. 40 | # Note the `np.add(inp, white_noise)` would have worked the same 41 | 42 | # Finally we can write the result like any pyotb object 43 | noisy_image.write('image_plus_noise.tif') 44 | ``` 45 | 46 | !!! warning 47 | 48 | - The whole image is loaded into memory 49 | - The georeference can not be modified. Thus, numpy operations can not 50 | change the image or pixel size 51 | 52 | ## Rasterio 53 | 54 | pyotb objects can also be exported in a format usable by rasterio. 55 | 56 | For example: 57 | 58 | ```python 59 | import pyotb 60 | import rasterio 61 | from scipy import ndimage 62 | 63 | # Pansharpening + NDVI + creating bare soils mask 64 | pxs = pyotb.BundleToPerfectSensor( 65 | inp='panchromatic.tif', 66 | inxs='multispectral.tif' 67 | ) 68 | ndvi = pyotb.RadiometricIndices({ 69 | 'in': pxs, 70 | 'channels.red': 3, 71 | 'channels.nir': 4, 72 | 'list': 'Vegetation:NDVI' 73 | }) 74 | bare_soil_mask = (ndvi < 0.3) 75 | 76 | # Exporting the result as array & profile usable by rasterio 77 | mask_array, profile = bare_soil_mask.to_rasterio() 78 | 79 | # Doing something in Python that is not possible with OTB, e.g. gathering 80 | # the contiguous groups of pixels with an integer index 81 | labeled_mask_array, nb_groups = ndimage.label(mask_array) 82 | 83 | # Writing the result to disk 84 | with rasterio.open('labeled_bare_soil.tif', 'w', **profile) as f: 85 | f.write(labeled_mask_array) 86 | ``` 87 | 88 | This way of exporting pyotb objects is more flexible that exporting to numpy, 89 | as the user gets the `profile` dictionary. 90 | If the georeference or pixel size is modified, the user can update the 91 | `profile` accordingly. 92 | 93 | ## Tensorflow 94 | 95 | We saw that numpy operations had some limitations. To bypass those 96 | limitations, it is possible to use some Tensorflow operations on pyotb objects. 97 | 98 | You need a working installation of OTBTF >=3.0 for this and then the code is 99 | like this: 100 | 101 | ```python 102 | import pyotb 103 | 104 | def scalar_product(x1, x2): 105 | """This is a function composed of tensorflow operations.""" 106 | import tensorflow as tf 107 | return tf.reduce_sum(tf.multiply(x1, x2), axis=-1) 108 | 109 | # Compute the scalar product 110 | res = pyotb.run_tf_function(scalar_product)('image1.tif', 'image2.tif') 111 | 112 | # Magically, `res` is a pyotb object 113 | res.write('scalar_product.tif') 114 | ``` 115 | 116 | For some easy syntax, one can use `pyotb.run_tf_function` as a function 117 | decorator, such as: 118 | 119 | ```python 120 | import pyotb 121 | 122 | # The `pyotb.run_tf_function` decorator enables the use of pyotb objects as 123 | # inputs/output of the function 124 | @pyotb.run_tf_function 125 | def scalar_product(x1, x2): 126 | import tensorflow as tf 127 | return tf.reduce_sum(tf.multiply(x1, x2), axis=-1) 128 | 129 | res = scalar_product('image1.tif', 'image2.tif') 130 | # Magically, `res` is a pyotb object 131 | ``` 132 | 133 | Advantages : 134 | 135 | - The process supports streaming, hence the whole image is **not** loaded into 136 | memory 137 | - Can be integrated in OTB pipelines 138 | 139 | !!! warning 140 | 141 | Due to compilation issues in OTBTF before version 4.0.0, tensorflow and 142 | pyotb can't be imported in the same python code. This problem has been 143 | fixed in OTBTF 4.0.0. 144 | -------------------------------------------------------------------------------- /doc/managing_loggers.md: -------------------------------------------------------------------------------- 1 | ## Managing loggers 2 | 3 | Several environment variables are used in order to adjust logger level and 4 | behaviour. It should be set before importing pyotb. 5 | 6 | - `OTB_LOGGER_LEVEL` : used to set the default OTB logger level. 7 | - `PYOTB_LOGGER_LEVEL` : used to set the pyotb logger level. 8 | 9 | If `PYOTB_LOGGER_LEVEL` isn't set, `OTB_LOGGER_LEVEL` will be used. 10 | If none of those two variables is set, the logger level will be set to 'INFO'. 11 | Available levels are : DEBUG, INFO, WARNING, ERROR, CRITICAL 12 | 13 | You may also change the logger level after import (for pyotb only) 14 | using pyotb.logger.setLevel(level). 15 | 16 | ```python 17 | import pyotb 18 | pyotb.logger.setLevel('DEBUG') 19 | ``` 20 | 21 | Bonus : in some cases, you may want to silence the GDAL driver logger 22 | (for example you will see a lot of errors when reading GML files with OGR). 23 | One useful trick is to redirect these logs to a file. This can be done using 24 | the variable `CPL_LOG`. 25 | 26 | ## Log to file 27 | It is possible to change the behaviour of the default pyotb logger as follow 28 | 29 | ```py 30 | import logging 31 | import pyotb 32 | # Optional : remove default stdout handler (but OTB will still print its own log) 33 | pyotb.logger.handlers.pop() 34 | # Add file handler 35 | handler = logging.FileHandler("/my/log/file.log") 36 | handler.setLevel("DEBUG") 37 | pyotb.logger.addHandler(handler) 38 | ``` 39 | 40 | For more advanced configuration and to manage conflicts between several loggers, 41 | see the [logging module docs](https://docs.python.org/3/howto/logging-cookbook.html) 42 | and use the `dictConfig()` function to configure your own logger. 43 | 44 | ## Named applications in logs 45 | 46 | It is possible to change an app name in order to track it easily in the logs : 47 | 48 | ```python 49 | import os 50 | os.environ['PYOTB_LOGGER_LEVEL'] = 'DEBUG' 51 | import pyotb 52 | 53 | bm = pyotb.BandMath(['image.tif'], exp='im1b1 * 100') 54 | bm.name = 'CustomBandMathApp' 55 | bm.execute() 56 | ``` 57 | 58 | ```text 59 | 2022-06-14 14:22:38 (DEBUG) [pyOTB] CustomBandMathApp: run execute() with parameters={'exp': 'im1b1 * 100', 'il': ['/home/vidlb/Téléchargements/test_4b.tif']} 60 | 2022-06-14 14:22:38 (INFO) BandMath: Image #1 has 4 components 61 | 2022-06-14 14:22:38 (DEBUG) [pyOTB] CustomBandMathApp: execution succeeded 62 | ``` 63 | -------------------------------------------------------------------------------- /doc/otb_versions.md: -------------------------------------------------------------------------------- 1 | ## System with multiple OTB versions 2 | 3 | If you want to quickly switch between OTB versions, or override the default 4 | system version, you may use the `OTB_ROOT` env variable : 5 | 6 | ```python 7 | import os 8 | # This is equivalent to "[set/export] OTB_ROOT=/opt/otb" before launching python 9 | os.environ['OTB_ROOT'] = '/opt/otb' 10 | import pyotb 11 | ``` 12 | 13 | ```text 14 | 2022-06-14 13:59:03 (INFO) [pyOTB] Preparing environment for OTB in /opt/otb 15 | 2022-06-14 13:59:04 (INFO) [pyOTB] Successfully loaded 126 OTB applications 16 | ``` 17 | 18 | If you try to import pyotb without having set environment, it will try to find 19 | any OTB version installed on your system: 20 | 21 | ```python 22 | import pyotb 23 | ``` 24 | 25 | ```text 26 | 2022-06-14 13:55:41 (INFO) [pyOTB] Failed to import OTB. Searching for it... 27 | 2022-06-14 13:55:41 (INFO) [pyOTB] Found /opt/otb/lib/otb/ 28 | 2022-06-14 13:55:41 (INFO) [pyOTB] Found /opt/otbtf/lib/otb 29 | 2022-06-14 13:55:42 (INFO) [pyOTB] Found /home/otbuser/Applications/OTB-8.0.1-Linux64 30 | 2022-06-14 13:55:43 (INFO) [pyOTB] Preparing environment for OTB in /home/otbuser/Applications/OTB-8.0.1-Linux64 31 | 2022-06-14 13:55:44 (INFO) [pyOTB] Successfully loaded 117 OTB applications 32 | ``` 33 | 34 | Here is the path precedence for this automatic env configuration : 35 | 36 | ```text 37 | OTB_ROOT env variable > python bindings directory 38 | OR search for releases installations : HOME 39 | OR (for linux) : /opt/otbtf > /opt/otb > /usr/local > /usr 40 | OR (for windows) : C:/Program Files 41 | ``` 42 | 43 | !!! Note 44 | 45 | When `otbApplication` is found in `PYTHONPATH` (and `OTB_ROOT` not set), 46 | the OTB installation where the python API is linked, will be used. 47 | 48 | ## Fresh OTB installation 49 | 50 | If you've just installed OTB binaries in a Linux environment, you may 51 | encounter an error at first import, pyotb will help you fix it : 52 | 53 | ```python 54 | import pyotb 55 | ``` 56 | 57 | ```text 58 | 2022-06-14 14:00:34 (INFO) [pyOTB] Preparing environment for OTB in /home/otbuser/Applications/OTB-8.0.1-Linux64 59 | 2022-07-07 16:56:04 (CRITICAL) [pyOTB] An error occurred while importing OTB Python API 60 | 2022-07-07 16:56:04 (CRITICAL) [pyOTB] OTB error message was 'libpython3.8.so.rh-python38-1.0: cannot open shared object file: No such file or directory' 61 | 2022-07-07 16:56:04 (CRITICAL) [pyOTB] It seems like you need to symlink or recompile python bindings 62 | 2022-07-07 16:56:04 (CRITICAL) [pyOTB] Use 'ln -s /usr/lib/x86_64-linux-gnu/libpython3.8.so /home/otbuser/Applications/OTB-8.0.1-Linux64/lib/libpython3.8.so.rh-python38-1.0' 63 | 64 | # OR in case Python version is not 3.8 and cmake is installed : 65 | 2022-07-07 16:54:34 (CRITICAL) [pyOTB] Python library version mismatch (OTB was expecting 3.8) : a simple symlink may not work, depending on your python version 66 | 2022-07-07 16:54:34 (CRITICAL) [pyOTB] To recompile python bindings, use 'cd /home/otbuser/Applications/OTB-8.0.1-Linux64 ; source otbenv.profile ; ctest -S share/otb/swig/build_wrapping.cmake -VV' 67 | 68 | Failed to import OTB. Exiting. 69 | ``` 70 | -------------------------------------------------------------------------------- /doc/quickstart.md: -------------------------------------------------------------------------------- 1 | ## Quickstart: running an OTB application with pyotb 2 | 3 | pyotb has been written so that it is more convenient to run an application in 4 | Python. 5 | 6 | You can pass the parameters of an application as a dictionary : 7 | 8 | ```python 9 | import pyotb 10 | resampled = pyotb.RigidTransformResample({ 11 | 'in': 'my_image.tif', 12 | 'transform.type.id.scaley': 0.5, 13 | 'interpolator': 'linear', 14 | 'transform.type.id.scalex': 0.5 15 | }) 16 | ``` 17 | 18 | For now, `resampled` has not been executed. Indeed, pyotb has a 'lazy' 19 | evaluation: applications are executed only when required. Generally, like in 20 | this example, executions happen to write output images to disk. 21 | 22 | To actually trigger the application execution, `write()` has to be called: 23 | 24 | ```python 25 | resampled.write('output.tif') # this is when the application actually runs 26 | ``` 27 | 28 | ### Using Python keyword arguments 29 | 30 | One can use the Python keyword arguments notation for passing that parameters: 31 | 32 | ```python 33 | output = pyotb.SuperImpose(inr='reference_image.tif', inm='image.tif') 34 | ``` 35 | 36 | Which is equivalent to: 37 | 38 | ```python 39 | output = pyotb.SuperImpose({'inr': 'reference_image.tif', 'inm': 'image.tif'}) 40 | ``` 41 | 42 | !!! warning 43 | 44 | For this notation, python doesn't accept the parameter `in` or any 45 | parameter that contains a dots (e.g. `io.in`). For `in` or other main 46 | input parameters of an OTB application, you may simply pass the value as 47 | first argument, pyotb will guess the parameter name. For parameters that 48 | contains dots, you can either use a dictionary, or replace dots (`.`) 49 | with underscores (`_`). 50 | 51 | Let's take the example of the `OrthoRectification` application of OTB, 52 | with the input image parameter named `io.in`: 53 | 54 | Option #1, keyword-arg-free: 55 | 56 | ```python 57 | ortho = pyotb.OrthoRectification('my_image.tif') 58 | ``` 59 | 60 | Option #2, replacing dots with underscores in parameter name: 61 | 62 | ```python 63 | ortho = pyotb.OrthoRectification(io_in='my_image.tif') 64 | ``` 65 | 66 | ## In-memory connections 67 | 68 | One nice feature of pyotb is in-memory connection between apps. It relies on 69 | the so-called [streaming](https://www.orfeo-toolbox.org/CookBook/C++/StreamingAndThreading.html) 70 | mechanism of OTB, that enables to process huge images with a limited memory 71 | footprint. 72 | 73 | pyotb allows to pass any application's output to another. This enables to 74 | build pipelines composed of several applications. 75 | 76 | Let's start from our previous example. Consider the case where one wants to 77 | resample the image, then apply optical calibration and binary morphological 78 | dilatation. We can write the following code to build a pipeline that will 79 | generate the output in an end-to-end fashion, without being limited with the 80 | input image size or writing temporary files. 81 | 82 | ```python 83 | import pyotb 84 | 85 | resampled = pyotb.RigidTransformResample({ 86 | 'in': 'my_image.tif', 87 | 'interpolator': 'linear', 88 | 'transform.type.id.scaley': 0.5, 89 | 'transform.type.id.scalex': 0.5 90 | }) 91 | 92 | calibrated = pyotb.OpticalCalibration({ 93 | 'in': resampled, 94 | 'level': 'toa' 95 | }) 96 | 97 | dilated = pyotb.BinaryMorphologicalOperation({ 98 | 'in': calibrated, 99 | 'out': 'output.tif', 100 | 'filter': 'dilate', 101 | 'structype': 'ball', 102 | 'xradius': 3, 103 | 'yradius': 3 104 | }) 105 | ``` 106 | 107 | We just have built our first pipeline! At this point, it's all symbolic since 108 | no computation has been performed. To trigger our pipeline, one must call the 109 | `write()` method from the pipeline termination: 110 | 111 | ```python 112 | dilated.write('output.tif') 113 | ``` 114 | 115 | In the next section, we will detail how `write()` works. 116 | 117 | ## Writing the result of an app 118 | 119 | Any pyotb object can be written to disk using `write()`. 120 | 121 | Let's consider the following pyotb application instance: 122 | 123 | ```python 124 | import pyotb 125 | resampled = pyotb.RigidTransformResample({ 126 | 'in': 'my_image.tif', 127 | 'interpolator': 'linear', 128 | 'transform.type.id.scaley': 0.5, 129 | 'transform.type.id.scalex': 0.5 130 | }) 131 | ``` 132 | 133 | We can then write the output of `resampled` as following: 134 | 135 | ```python 136 | resampled.write('output.tif') 137 | ``` 138 | 139 | !!! note 140 | 141 | For applications that have multiple outputs, passing a `dict` of filenames 142 | can be considered. Let's take the example of `MeanShiftSmoothing` which 143 | has 2 output images: 144 | 145 | ```python 146 | import pyotb 147 | meanshift = pyotb.MeanShiftSmoothing('my_image.tif') 148 | meanshift.write({'fout': 'output_1.tif', 'foutpos': 'output_2.tif'}) 149 | ``` 150 | 151 | Another possibility for writing results is to set the output parameter when 152 | initializing the application: 153 | 154 | ```python 155 | import pyotb 156 | 157 | resampled = pyotb.RigidTransformResample({ 158 | 'in': 'my_image.tif', 159 | 'interpolator': 'linear', 160 | 'out': 'output.tif', 161 | 'transform.type.id.scaley': 0.5, 162 | 'transform.type.id.scalex': 0.5 163 | }) 164 | ``` 165 | 166 | ### Pixel type 167 | 168 | Setting the pixel type is optional, and can be achieved setting the 169 | `pixel_type` argument: 170 | 171 | ```python 172 | resampled.write('output.tif', pixel_type='uint16') 173 | ``` 174 | 175 | The value of `pixel_type` corresponds to the name of a pixel type from OTB 176 | applications (e.g. `'uint8'`, `'float'`, etc). 177 | 178 | ### Extended filenames 179 | 180 | Extended filenames can be passed as `str` or `dict`. 181 | 182 | As `str`: 183 | 184 | ```python 185 | resampled.write( 186 | ... 187 | ext_fname='nodata=65535&box=0:0:256:256' 188 | ) 189 | ``` 190 | 191 | As `dict`: 192 | 193 | ```python 194 | resampled.write( 195 | ... 196 | ext_fname={'nodata': '65535', 'box': '0:0:256:256'} 197 | ) 198 | ``` 199 | 200 | !!! info 201 | 202 | When `ext_fname` is provided and the output filenames contain already some 203 | extended filename pattern, the ones provided in the filenames take 204 | priority over the ones passed in `ext_fname`. This allows to fine-grained 205 | tune extended filenames for each output, with a common extended filenames 206 | keys/values basis. 207 | -------------------------------------------------------------------------------- /doc/summarize.md: -------------------------------------------------------------------------------- 1 | ## Summarize applications 2 | 3 | pyotb enables to summarize applications as a dictionary with keys/values for 4 | parameters. This feature can be used to keep track of a process, composed of 5 | multiple applications chained together. 6 | 7 | ### Single application 8 | 9 | Let's take the example of one single application. 10 | 11 | ```python 12 | import pyotb 13 | 14 | app = pyotb.RigidTransformResample({ 15 | 'in': 'my_image.tif', 16 | 'interpolator': 'linear', 17 | 'transform.type.id.scaley': 0.5, 18 | 'transform.type.id.scalex': 0.5 19 | }) 20 | ``` 21 | 22 | The application can be summarized using `pyotb.summarize()` or 23 | `app.summary()`, which are equivalent. 24 | 25 | ```python 26 | print(app.summarize()) 27 | ``` 28 | 29 | Results in the following (lines have been pretty printed for the sake of 30 | documentation): 31 | 32 | ```json lines 33 | { 34 | 'name': 'RigidTransformResample', 35 | 'parameters': { 36 | 'transform.type': 'id', 37 | 'in': 'my_image.tif', 38 | 'interpolator': 'linear', 39 | 'transform.type.id.scaley': 0.5, 40 | 'transform.type.id.scalex': 0.5 41 | } 42 | } 43 | ``` 44 | 45 | Note that we can also summarize an application after it has been executed: 46 | 47 | ```python 48 | app.write('output.tif', pixel_type='uint16') 49 | print(app.summarize()) 50 | ``` 51 | 52 | Which results in the following: 53 | 54 | ```json lines 55 | { 56 | 'name': 'RigidTransformResample', 57 | 'parameters': { 58 | 'transform.type': 'id', 59 | 'in': 'my_image.tif', 60 | 'interpolator': 'linear', 61 | 'transform.type.id.scaley': 0.5, 62 | 'transform.type.id.scalex': 0.5, 63 | 'out': 'output.tif' 64 | } 65 | } 66 | ``` 67 | 68 | Now `'output.tif'` has been added to the application parameters. 69 | 70 | ### Multiple applications chained together (pipeline) 71 | 72 | When multiple applications are chained together, the summary of the last 73 | application will describe all upstream processes. 74 | 75 | ```python 76 | import pyotb 77 | 78 | app1 = pyotb.RigidTransformResample({ 79 | 'in': 'my_image.tif', 80 | 'interpolator': 'linear', 81 | 'transform.type.id.scaley': 0.5, 82 | 'transform.type.id.scalex': 0.5 83 | }) 84 | app2 = pyotb.Smoothing(app1) 85 | print(app2.summarize()) 86 | ``` 87 | 88 | Results in: 89 | 90 | ```json lines 91 | { 92 | 'name': 'Smoothing', 93 | 'parameters': { 94 | 'type': 'anidif', 95 | 'type.anidif.timestep': 0.125, 96 | 'type.anidif.nbiter': 10, 97 | 'type.anidif.conductance': 1.0, 98 | 'in': { 99 | 'name': 'RigidTransformResample', 100 | 'parameters': { 101 | 'transform.type': 'id', 102 | 'in': 'my_image.tif', 103 | 'interpolator': 'linear', 104 | 'transform.type.id.scaley': 0.5, 105 | 'transform.type.id.scalex': 0.5 106 | } 107 | } 108 | } 109 | } 110 | ``` 111 | 112 | ### Remote files URL stripping 113 | 114 | Cloud-based raster URLs often include tokens or random strings resulting from 115 | the URL signing. 116 | Those can be removed from the summarized paths, using the `strip_inpath` 117 | and/or `strip_outpath` arguments respectively for inputs and/or outputs. 118 | 119 | Here is an example with Microsoft Planetary Computer: 120 | 121 | ```python 122 | import planetary_computer 123 | import pyotb 124 | 125 | url = ( 126 | "https://sentinel2l2a01.blob.core.windows.net/sentinel2-l2/31/N/EA/2023/" 127 | "11/03/S2A_MSIL2A_20231103T095151_N0509_R079_T31NEA_20231103T161409.SAFE/" 128 | "GRANULE/L2A_T31NEA_A043691_20231103T100626/IMG_DATA/R10m/T31NEA_20231103" 129 | "T095151_B02_10m.tif" 130 | ) 131 | signed_url = planetary_computer.sign_inplace(url) 132 | app = pyotb.Smoothing(signed_url) 133 | ``` 134 | 135 | By default, the summary does not strip the URL. 136 | 137 | ```python 138 | print(app.summarize()["parameters"]["in"]) 139 | ``` 140 | 141 | This results in: 142 | 143 | ``` 144 | /vsicurl/https://sentinel2l2a01.blob.core.windows.net/sentinel2-l2/31/N/EA/... 145 | 2023/11/03/S2A_MSIL2A_20231103T095151_N0509_R079_T31NEA_20231103T161409.SAFE... 146 | /GRANULE/L2A_T31NEA_A043691_20231103T100626/IMG_DATA/R10m/T31NEA_20231103T... 147 | 095151_B02_10m.tif?st=2023-11-07T15%3A52%3A47Z&se=2023-11-08T16%3A37%3A47Z&... 148 | sp=rl&sv=2021-06-08&sr=c&skoid=c85c15d6-d1ae-42d4-af60-e2ca0f81359b&sktid=... 149 | 72f988bf-86f1-41af-91ab-2d7cd011db47&skt=2023-11-08T11%3A41%3A41Z&ske=2023-... 150 | 11-15T11%3A41%3A41Z&sks=b&skv=2021-06-08&sig=xxxxxxxxxxx...xxxxx 151 | ``` 152 | 153 | Now we can strip the URL to keep only the resource identifier and get rid of 154 | the token: 155 | 156 | ```python 157 | print(app.summarize(strip_inpath=True)["parameters"]["in"]) 158 | ``` 159 | 160 | Which now results in: 161 | 162 | ``` 163 | /vsicurl/https://sentinel2l2a01.blob.core.windows.net/sentinel2-l2/31/N/EA/... 164 | 2023/11/03/S2A_MSIL2A_20231103T095151_N0509_R079_T31NEA_20231103T161409.SAFE... 165 | /GRANULE/L2A_T31NEA_A043691_20231103T100626/IMG_DATA/R10m/T31NEA_20231103T... 166 | 095151_B02_10m.tif 167 | ``` -------------------------------------------------------------------------------- /doc/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | ## Migration from pyotb 1.5.4 (oct 2022) to 2.x.y 4 | 5 | List of breaking changes: 6 | 7 | - `otbObject` has ben renamed `OTBObject` 8 | - `otbObject.get_infos()` has been renamed `OTBObject.get_info()` 9 | - `otbObject.key_output_image` has been renamed `OTBObject.output_image_key` 10 | - `otbObject.key_input_image` has been renamed `OTBObject.input_image_key` 11 | - `otbObject.read_values_at_coords()` has been renamed `OTBObject.get_values_at_coords()` 12 | - `otbObject.xy_to_rowcol()` has been renamed `OTBObject.get_rowcol_from_xy()` 13 | - `App.output_param` has been replaced with `App.output_image_key` 14 | - `App.write()` argument `filename_extension` has been renamed `ext_fname` 15 | - `App.save_objects()` has been renamed `App.__sync_parameters()` 16 | - use `pyotb_app['paramname']` or `pyotb_app.app.GetParameterValue('paramname')` instead of `pyotb_app.GetParameterValue('paramname')` to access parameter `paramname` value 17 | - use `pyotb_app['paramname']` instead of `pyotb_app.paramname` to access parameter `paramname` value 18 | - `Output.__init__()` arguments `app` and `output_parameter_key` have been renamed `pyotb_app` and `param_key` 19 | - `Output.pyotb_app` has been renamed `Output.parent_pyotb_app` 20 | - `logicalOperation` has been renamed `LogicalOperation` 21 | 22 | ## Known limitations with old versions 23 | 24 | !!! note 25 | 26 | All defects described below have been fixed since OTB 8.1.2 and pyotb 2.0.0 27 | 28 | ### Failure of intermediate writing (otb < 8.1, pyotb < 1.5.4) 29 | 30 | When chaining applications in-memory, there may be some problems when writing 31 | intermediate results, depending on the order 32 | the writings are requested. Some examples can be found below: 33 | 34 | #### Example of failures involving slicing 35 | 36 | For some applications (non-exhaustive know list: OpticalCalibration, 37 | DynamicConvert, BandMath), we can face unexpected failures when using channels 38 | slicing 39 | 40 | ```python 41 | import pyotb 42 | 43 | inp = pyotb.DynamicConvert('raster.tif') 44 | one_band = inp[:, :, 1] 45 | 46 | # this works 47 | one_band.write('one_band.tif') 48 | 49 | # this works 50 | one_band.write('one_band.tif') 51 | inp.write('stretched.tif') 52 | 53 | # this does not work 54 | inp.write('stretched.tif') 55 | one_band.write('one_band.tif') # Failure here 56 | ``` 57 | 58 | When writing is triggered right after the application declaration, no problem occurs: 59 | 60 | ```python 61 | import pyotb 62 | 63 | inp = pyotb.DynamicConvert('raster.tif') 64 | inp.write('stretched.tif') 65 | 66 | one_band = inp[:, :, 1] 67 | one_band.write('one_band.tif') # no failure 68 | ``` 69 | 70 | Also, when using only spatial slicing, no issue has been reported: 71 | 72 | ```python 73 | import pyotb 74 | 75 | inp = pyotb.DynamicConvert('raster.tif') 76 | one_band = inp[:100, :100, :] 77 | 78 | # this works 79 | inp.write('stretched.tif') 80 | one_band.write('one_band.tif') 81 | ``` 82 | 83 | #### Example of failures involving arithmetic operation 84 | 85 | One can meet errors when using arithmetic operations at the end of a pipeline 86 | when DynamicConvert, BandMath or OpticalCalibration is involved: 87 | 88 | ```python 89 | import pyotb 90 | 91 | inp = pyotb.DynamicConvert('raster.tif') 92 | inp_new = pyotb.ManageNoData(inp, mode='changevalue') 93 | absolute = abs(inp) 94 | 95 | # this does not work 96 | inp.write('one_band.tif') 97 | inp_new.write('one_band_nodata.tif') # Failure here 98 | absolute.write('absolute.tif') # Failure here 99 | ``` 100 | 101 | When writing only the final result, i.e. the end of the pipeline (`absolute.write('absolute.tif')`), there is no problem. 102 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | # mkdocs.yml 2 | theme: 3 | name: "readthedocs" 4 | icon: 5 | repo: fontawesome/brands/gitlab 6 | features: 7 | - content.code.annotate 8 | - navigation.tabs 9 | - toc.follow 10 | analytics: 11 | - gtag: G-PD85X2X108 12 | custom_dir: doc/custom_theme 13 | 14 | plugins: 15 | - search 16 | - gen-files: 17 | scripts: 18 | - doc/gen_ref_pages.py 19 | - mkdocstrings: 20 | watch: 21 | - pyotb/ 22 | - literate-nav: 23 | nav_file: SUMMARY.md 24 | - section-index 25 | - mermaid2 26 | 27 | nav: 28 | - Home: index.md 29 | - Get Started: 30 | - Installation of pyotb: installation.md 31 | - How to use pyotb: quickstart.md 32 | - Useful features: features.md 33 | - Functions: functions.md 34 | - Interaction with Python libraries (numpy, rasterio, tensorflow): interaction.md 35 | - Examples: 36 | - Pleiades data processing: examples/pleiades.md 37 | - Nodata mean: examples/nodata_mean.md 38 | - Advanced use: 39 | - Comparison between pyotb and OTB native library: comparison_otb.md 40 | - OTB versions: otb_versions.md 41 | - Managing loggers: managing_loggers.md 42 | - Troubleshooting & limitations: troubleshooting.md 43 | - API: 44 | - pyotb: 45 | - core: reference/pyotb/core.md 46 | - apps: reference/pyotb/apps.md 47 | - functions: reference/pyotb/functions.md 48 | - helpers: reference/pyotb/helpers.md 49 | 50 | # Customization 51 | extra: 52 | feature: 53 | tabs: true 54 | social: 55 | - icon: fontawesome/brands/gitlab 56 | link: https://forgemia.inra.fr/orfeo-toolbox/pyotb 57 | extra_css: 58 | - https://gitlab.orfeo-toolbox.org/orfeotoolbox/otb/-/raw/8.1.2-rc1/Documentation/Cookbook/_static/css/otb_theme.css 59 | - extra.css 60 | use_directory_urls: false # this creates some pyotb/core.html pages instead of pyotb/core/index.html 61 | 62 | markdown_extensions: 63 | - admonition 64 | - toc: 65 | permalink: true 66 | title: On this page 67 | toc_depth: 1-2 68 | - pymdownx.highlight: 69 | anchor_linenums: true 70 | - pymdownx.details 71 | - pymdownx.superfences: 72 | custom_fences: 73 | - name: python 74 | class: python 75 | format: !!python/name:pymdownx.superfences.fence_code_format 76 | 77 | # Rest of the navigation. 78 | site_name: "pyotb: Orfeo ToolBox for Python" 79 | repo_url: https://forgemia.inra.fr/orfeo-toolbox/pyotb 80 | repo_name: pyotb 81 | docs_dir: doc/ 82 | -------------------------------------------------------------------------------- /pyotb/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """This module provides convenient python wrapping of otbApplications.""" 3 | __version__ = "2.2.1" 4 | 5 | from .helpers import logger 6 | from .core import ( 7 | OTBObject, 8 | App, 9 | Input, 10 | Output, 11 | get_nbchannels, 12 | get_pixel_type, 13 | summarize, 14 | ) 15 | from .apps import * 16 | 17 | from .functions import ( # pylint: disable=redefined-builtin 18 | all, 19 | any, 20 | clip, 21 | define_processing_area, 22 | run_tf_function, 23 | where, 24 | ) 25 | -------------------------------------------------------------------------------- /pyotb/apps.py: -------------------------------------------------------------------------------- 1 | """Search for OTB (set env if necessary), subclass core.App for each available application.""" 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | 7 | import otbApplication as otb # pylint: disable=import-error 8 | 9 | from .core import App 10 | from .helpers import logger 11 | 12 | 13 | def get_available_applications() -> tuple[str]: 14 | """Find available OTB applications. 15 | 16 | Returns: 17 | tuple of available applications 18 | 19 | Raises: 20 | SystemExit: if no application is found 21 | 22 | """ 23 | app_list = otb.Registry.GetAvailableApplications() 24 | if app_list: 25 | logger.info("Successfully loaded %s OTB applications", len(app_list)) 26 | return app_list 27 | raise SystemExit( 28 | "Unable to load applications. Set env variable OTB_APPLICATION_PATH and try again." 29 | ) 30 | 31 | 32 | class OTBTFApp(App): 33 | """Helper for OTBTF to ensure the nb_sources variable is set.""" 34 | 35 | @staticmethod 36 | def set_nb_sources(*args, n_sources: int = None): 37 | """Set the number of sources of TensorflowModelServe. Can be either user-defined or deduced from the args. 38 | 39 | Args: 40 | *args: arguments (dict). NB: we don't need kwargs because it cannot contain source#.il 41 | n_sources: number of sources. Default is None (resolves the number of sources based on the 42 | content of the dict passed in args, where some 'source' str is found) 43 | 44 | """ 45 | if n_sources: 46 | os.environ["OTB_TF_NSOURCES"] = str(int(n_sources)) 47 | else: 48 | # Retrieving the number of `source#.il` parameters 49 | params_dic = { 50 | k: v for arg in args if isinstance(arg, dict) for k, v in arg.items() 51 | } 52 | n_sources = len( 53 | [k for k in params_dic if "source" in k and k.endswith(".il")] 54 | ) 55 | if n_sources >= 1: 56 | os.environ["OTB_TF_NSOURCES"] = str(n_sources) 57 | 58 | def __init__(self, name: str, *args, n_sources: int = None, **kwargs): 59 | """Constructor for an OTBTFApp object. 60 | 61 | Args: 62 | name: name of the OTBTF app 63 | n_sources: number of sources. Default is None (resolves the number of sources based on the 64 | content of the dict passed in args, where some 'source' str is found) 65 | 66 | """ 67 | self.set_nb_sources(*args, n_sources=n_sources) 68 | super().__init__(name, *args, **kwargs) 69 | 70 | 71 | AVAILABLE_APPLICATIONS = get_available_applications() 72 | 73 | # This is to enable aliases of Apps, i.e. `pyotb.AppName(...)` instead of `pyotb.App("AppName", ...)` 74 | _CODE_TEMPLATE = """ 75 | class {name}(App): 76 | def __init__(self, *args, **kwargs): 77 | super().__init__('{name}', *args, **kwargs) 78 | """ 79 | 80 | for _app in AVAILABLE_APPLICATIONS: 81 | # Customize the behavior for some OTBTF applications. `OTB_TF_NSOURCES` is now handled by pyotb 82 | if _app in ("PatchesExtraction", "TensorflowModelTrain", "TensorflowModelServe"): 83 | exec( # pylint: disable=exec-used 84 | _CODE_TEMPLATE.format(name=_app).replace("(App)", "(OTBTFApp)") 85 | ) 86 | # Default behavior for any OTB application 87 | else: 88 | exec(_CODE_TEMPLATE.format(name=_app)) # pylint: disable=exec-used 89 | -------------------------------------------------------------------------------- /pyotb/functions.py: -------------------------------------------------------------------------------- 1 | """This module provides a set of functions for pyotb.""" 2 | 3 | from __future__ import annotations 4 | 5 | import inspect 6 | import os 7 | import subprocess 8 | import sys 9 | import textwrap 10 | import uuid 11 | from collections import Counter 12 | from pathlib import Path 13 | 14 | from .core import App, Input, LogicalOperation, Operation, get_nbchannels 15 | from .helpers import logger 16 | 17 | 18 | def where(cond: App | str, x: App | str | float, y: App | str | float) -> Operation: 19 | """Functionally similar to numpy.where. Where cond is True (!=0), returns x. Else returns y. 20 | 21 | If cond is monoband whereas x or y are multiband, cond channels are expanded to match x & y ones. 22 | 23 | Args: 24 | cond: condition, must be a raster (filepath, App, Operation...). 25 | x: value if cond is True. Can be: float, int, App, filepath, Operation... 26 | y: value if cond is False. Can be: float, int, App, filepath, Operation... 27 | 28 | Returns: 29 | an output where pixels are x if cond is True, else y 30 | 31 | Raises: 32 | ValueError: if x and y have different number of bands 33 | 34 | """ 35 | # Checking the number of bands of rasters. Several cases : 36 | # - if cond is monoband, x and y can be multibands. Then cond will adapt to match x and y nb of bands 37 | # - if cond is multiband, x and y must have the same nb of bands if they are rasters. 38 | x_nb_channels, y_nb_channels = None, None 39 | if not isinstance(x, (int, float)): 40 | x_nb_channels = get_nbchannels(x) 41 | if not isinstance(y, (int, float)): 42 | y_nb_channels = get_nbchannels(y) 43 | if x_nb_channels and y_nb_channels: 44 | if x_nb_channels != y_nb_channels: 45 | raise ValueError( 46 | "X and Y images do not have the same number of bands. " 47 | f"X has {x_nb_channels} bands whereas Y has {y_nb_channels} bands" 48 | ) 49 | 50 | x_or_y_nb_channels = x_nb_channels if x_nb_channels else y_nb_channels 51 | cond_nb_channels = get_nbchannels(cond) 52 | if ( 53 | cond_nb_channels != 1 54 | and x_or_y_nb_channels 55 | and cond_nb_channels != x_or_y_nb_channels 56 | ): 57 | raise ValueError( 58 | "Condition and X&Y do not have the same number of bands. Condition has " 59 | f"{cond_nb_channels} bands whereas X&Y have {x_or_y_nb_channels} bands" 60 | ) 61 | # If needed, duplicate the single band binary mask to multiband to match the dimensions of x & y 62 | if cond_nb_channels == 1 and x_or_y_nb_channels and x_or_y_nb_channels != 1: 63 | logger.info( 64 | "The condition has one channel whereas X/Y has/have %s channels. Expanding number" 65 | " of channels of condition to match the number of channels of X/Y", 66 | x_or_y_nb_channels, 67 | ) 68 | # Get the number of bands of the result 69 | out_nb_channels = x_or_y_nb_channels or cond_nb_channels 70 | 71 | return Operation("?", cond, x, y, nb_bands=out_nb_channels) 72 | 73 | 74 | def clip(image: App | str, v_min: App | str | float, v_max: App | str | float): 75 | """Clip values of image in a range of values. 76 | 77 | Args: 78 | image: input raster, can be filepath or any pyotb object 79 | v_min: minimum value of the range 80 | v_max: maximum value of the range 81 | 82 | Returns: 83 | raster whose values are clipped in the range 84 | 85 | """ 86 | if isinstance(image, (str, Path)): 87 | image = Input(image) 88 | return where(image <= v_min, v_min, where(image >= v_max, v_max, image)) 89 | 90 | 91 | def all(*inputs): # pylint: disable=redefined-builtin 92 | """Check if value is different than 0 everywhere along the band axis. 93 | 94 | For only one image, this function checks that all bands of the image are True (i.e. !=0) 95 | and outputs a singleband boolean raster 96 | For several images, this function checks that all images are True (i.e. !=0) and outputs 97 | a boolean raster, with as many bands as the inputs 98 | 99 | Args: 100 | inputs: inputs can be 1) a single image or 2) several images, either passed as separate arguments 101 | or inside a list 102 | 103 | Returns: 104 | AND intersection 105 | 106 | """ 107 | # If necessary, flatten inputs 108 | if len(inputs) == 1 and isinstance(inputs[0], (list, tuple)): 109 | inputs = inputs[0] 110 | # Add support for generator inputs (to have the same behavior as built-in `all` function) 111 | if ( 112 | isinstance(inputs, tuple) 113 | and len(inputs) == 1 114 | and inspect.isgenerator(inputs[0]) 115 | ): 116 | inputs = list(inputs[0]) 117 | # Transforming potential filepaths to pyotb objects 118 | inputs = [Input(inp) if isinstance(inp, str) else inp for inp in inputs] 119 | 120 | # Checking that all bands of the single image are True 121 | if len(inputs) == 1: 122 | inp = inputs[0] 123 | if isinstance(inp, LogicalOperation): 124 | res = inp[:, :, 0] 125 | else: 126 | res = inp[:, :, 0] != 0 127 | for band in range(1, inp.shape[-1]): 128 | if isinstance(inp, LogicalOperation): 129 | res = res & inp[:, :, band] 130 | else: 131 | res = res & (inp[:, :, band] != 0) 132 | return res 133 | 134 | # Checking that all images are True 135 | if isinstance(inputs[0], LogicalOperation): 136 | res = inputs[0] 137 | else: 138 | res = inputs[0] != 0 139 | for inp in inputs[1:]: 140 | if isinstance(inp, LogicalOperation): 141 | res = res & inp 142 | else: 143 | res = res & (inp != 0) 144 | return res 145 | 146 | 147 | def any(*inputs): # pylint: disable=redefined-builtin 148 | """Check if value is different than 0 anywhere along the band axis. 149 | 150 | For only one image, this function checks that at least one band of the image is True (i.e. !=0) and outputs 151 | a single band boolean raster 152 | For several images, this function checks that at least one of the images is True (i.e. !=0) and outputs 153 | a boolean raster, with as many bands as the inputs 154 | 155 | Args: 156 | inputs: inputs can be 1) a single image or 2) several images, either passed as separate arguments 157 | or inside a list 158 | 159 | Returns: 160 | OR intersection 161 | 162 | """ 163 | # If necessary, flatten inputs 164 | if len(inputs) == 1 and isinstance(inputs[0], (list, tuple)): 165 | inputs = inputs[0] 166 | # Add support for generator inputs (to have the same behavior as built-in `any` function) 167 | if ( 168 | isinstance(inputs, tuple) 169 | and len(inputs) == 1 170 | and inspect.isgenerator(inputs[0]) 171 | ): 172 | inputs = list(inputs[0]) 173 | # Transforming potential filepaths to pyotb objects 174 | inputs = [Input(inp) if isinstance(inp, str) else inp for inp in inputs] 175 | 176 | # Checking that at least one band of the image is True 177 | if len(inputs) == 1: 178 | inp = inputs[0] 179 | if isinstance(inp, LogicalOperation): 180 | res = inp[:, :, 0] 181 | else: 182 | res = inp[:, :, 0] != 0 183 | 184 | for band in range(1, inp.shape[-1]): 185 | if isinstance(inp, LogicalOperation): 186 | res = res | inp[:, :, band] 187 | else: 188 | res = res | (inp[:, :, band] != 0) 189 | return res 190 | 191 | # Checking that at least one image is True 192 | if isinstance(inputs[0], LogicalOperation): 193 | res = inputs[0] 194 | else: 195 | res = inputs[0] != 0 196 | for inp in inputs[1:]: 197 | if isinstance(inp, LogicalOperation): 198 | res = res | inp 199 | else: 200 | res = res | (inp != 0) 201 | return res 202 | 203 | 204 | def run_tf_function(func): 205 | """This function enables using a function that calls some TF operations, with pyotb object as inputs. 206 | 207 | For example, you can write a function that uses TF operations like this : 208 | ```python 209 | @run_tf_function 210 | def multiply(input1, input2): 211 | import tensorflow as tf 212 | return tf.multiply(input1, input2) 213 | 214 | # Then you can use the function like this : 215 | result = multiply(pyotb_object1, pyotb_object1) # this is a pyotb object 216 | ``` 217 | 218 | Args: 219 | func: function taking one or several inputs and returning *one* output 220 | 221 | Returns: 222 | wrapper: a function that returns a pyotb object 223 | 224 | Raises: 225 | SystemError: if OTBTF apps are missing 226 | 227 | """ 228 | try: 229 | from .apps import TensorflowModelServe # pylint: disable=import-outside-toplevel 230 | except ImportError as err: 231 | raise SystemError( 232 | "Could not run Tensorflow function: failed to import TensorflowModelServe." 233 | "Check that you have OTBTF configured (https://github.com/remicres/otbtf#how-to-install)" 234 | ) from err 235 | 236 | def get_tf_pycmd(output_dir, channels, scalar_inputs): 237 | """Create a string containing all python instructions necessary to create and save the Keras model. 238 | 239 | Args: 240 | output_dir: directory under which to save the model 241 | channels: list of raster channels (int). Contain `None` entries for non-raster inputs 242 | scalar_inputs: list of scalars (int/float). Contain `None` entries for non-scalar inputs 243 | 244 | Returns: 245 | the whole string code for function definition + model saving 246 | 247 | """ 248 | # Getting the string definition of the tf function (e.g. "def multiply(x1, x2):...") 249 | # Maybe not entirely foolproof, maybe we should use dill instead? but it would add a dependency 250 | func_def_str = inspect.getsource(func) 251 | func_name = func.__name__ 252 | 253 | create_and_save_model_str = func_def_str 254 | # Adding the instructions to create the model and save it to output dir 255 | create_and_save_model_str += textwrap.dedent( 256 | f""" 257 | import tensorflow as tf 258 | 259 | model_inputs = [] 260 | tf_inputs = [] 261 | for channel, scalar_input in zip({channels}, {scalar_inputs}): 262 | if channel: 263 | input = tf.keras.Input((None, None, channel)) 264 | tf_inputs.append(input) 265 | model_inputs.append(input) 266 | else: 267 | if isinstance(scalar_input, int): # TF doesn't like mixing float and int 268 | scalar_input = float(scalar_input) 269 | tf_inputs.append(scalar_input) 270 | 271 | output = {func_name}(*tf_inputs) 272 | 273 | # Create and save the .pb model 274 | model = tf.keras.Model(inputs=model_inputs, outputs=output) 275 | model.save("{output_dir}") 276 | """ 277 | ) 278 | 279 | return create_and_save_model_str 280 | 281 | def wrapper(*inputs, tmp_dir="/tmp"): 282 | """For the user point of view, this function simply applies some TensorFlow operations to some rasters. 283 | 284 | Implicitly, it saves a .pb model that describe the TF operations, then creates an OTB ModelServe application 285 | that applies this .pb model to the inputs. 286 | 287 | Args: 288 | *inputs: a list of pyotb objects, filepaths or int/float numbers 289 | tmp_dir: directory where temporary models can be written (Default value = '/tmp') 290 | 291 | Returns: 292 | a pyotb object, output of TensorFlowModelServe 293 | 294 | """ 295 | # Get infos about the inputs 296 | channels = [] 297 | scalar_inputs = [] 298 | raster_inputs = [] 299 | for inp in inputs: 300 | try: 301 | # This is for raster input 302 | channel = get_nbchannels(inp) 303 | channels.append(channel) 304 | scalar_inputs.append(None) 305 | raster_inputs.append(inp) 306 | except TypeError: 307 | # This is for other inputs (float, int) 308 | channels.append(None) 309 | scalar_inputs.append(inp) 310 | 311 | # Create and save the model. This is executed **inside an independent process** because (as of 2022-03), 312 | # tensorflow python library and OTBTF are incompatible 313 | out_savedmodel = os.path.join(tmp_dir, f"tmp_otbtf_model_{uuid.uuid4()}") 314 | pycmd = get_tf_pycmd(out_savedmodel, channels, scalar_inputs) 315 | cmd_args = [sys.executable, "-c", pycmd] 316 | # TODO: remove subprocess execution since this issues has been fixed with OTBTF 4.0 317 | try: 318 | subprocess.run( 319 | cmd_args, 320 | env=os.environ, 321 | stdout=subprocess.PIPE, 322 | stderr=subprocess.PIPE, 323 | check=True, 324 | ) 325 | except subprocess.SubprocessError: 326 | logger.debug("Failed to call subprocess") 327 | if not os.path.isdir(out_savedmodel): 328 | logger.info("Failed to save the model") 329 | 330 | # Initialize the OTBTF model serving application 331 | model_serve = TensorflowModelServe( 332 | { 333 | "model.dir": out_savedmodel, 334 | "optim.disabletiling": "on", 335 | "model.fullyconv": "on", 336 | }, 337 | n_sources=len(raster_inputs), 338 | frozen=True, 339 | ) 340 | # Set parameters and execute 341 | for i, inp in enumerate(raster_inputs): 342 | model_serve.set_parameters({f"source{i + 1}.il": [inp]}) 343 | model_serve.execute() 344 | # Possible ENH: handle the deletion of the temporary model ? 345 | 346 | return model_serve 347 | 348 | return wrapper 349 | 350 | 351 | def define_processing_area( 352 | *args, 353 | window_rule: str = "intersection", 354 | pixel_size_rule: str = "minimal", 355 | interpolator: str = "nn", 356 | reference_window_input: dict = None, 357 | reference_pixel_size_input: str = None, 358 | ) -> list[App]: 359 | """Given several inputs, this function handles the potential resampling and cropping to same extent. 360 | 361 | WARNING: Not fully implemented / tested 362 | 363 | Args: 364 | *args: list of raster inputs. Can be str (filepath) or pyotb objects 365 | window_rule: Can be 'intersection', 'union', 'same_as_input', 'specify' (Default value = 'intersection') 366 | pixel_size_rule: Can be 'minimal', 'maximal', 'same_as_input', 'specify' (Default value = 'minimal') 367 | interpolator: Can be 'bco', 'nn', 'linear' (Default value = 'nn') 368 | reference_window_input: Required if window_rule = 'same_as_input' (Default value = None) 369 | reference_pixel_size_input: Required if pixel_size_rule = 'same_as_input' (Default value = None) 370 | 371 | Returns: 372 | list of in-memory pyotb objects with all the same resolution, shape and extent 373 | 374 | """ 375 | # Flatten all args into one list 376 | inputs = [] 377 | for arg in args: 378 | if isinstance(arg, (list, tuple)): 379 | inputs.extend(arg) 380 | else: 381 | inputs.append(arg) 382 | # Getting metadatas of inputs 383 | metadatas = {} 384 | for inp in inputs: 385 | if isinstance(inp, str): # this is for filepaths 386 | metadata = Input(inp).app.GetImageMetaData("out") 387 | elif isinstance(inp, App): 388 | metadata = inp.app.GetImageMetaData(inp.output_param) 389 | else: 390 | raise TypeError(f"Wrong input : {inp}") 391 | metadatas[inp] = metadata 392 | 393 | # Get a metadata of an arbitrary image. This is just to compare later with other images 394 | any_metadata = next(iter(metadatas.values())) 395 | # Checking if all images have the same projection 396 | if not all( 397 | metadata["ProjectionRef"] == any_metadata["ProjectionRef"] 398 | for metadata in metadatas.values() 399 | ): 400 | logger.warning( 401 | "All images may not have the same CRS, which might cause unpredictable results" 402 | ) 403 | 404 | # Handling different spatial footprints 405 | # TODO: find possible bug - ImageMetaData is not updated when running an app 406 | # cf https://gitlab.orfeo-toolbox.org/orfeotoolbox/otb/-/issues/2234. Should we use ImageOrigin instead? 407 | if not all( 408 | md["UpperLeftCorner"] == any_metadata["UpperLeftCorner"] 409 | and md["LowerRightCorner"] == any_metadata["LowerRightCorner"] 410 | for md in metadatas.values() 411 | ): 412 | # Retrieving the bounding box that will be common for all inputs 413 | if window_rule == "intersection": 414 | # The coordinates depend on the orientation of the axis of projection 415 | if any_metadata["GeoTransform"][1] >= 0: 416 | ulx = max(md["UpperLeftCorner"][0] for md in metadatas.values()) 417 | lrx = min(md["LowerRightCorner"][0] for md in metadatas.values()) 418 | else: 419 | ulx = min(md["UpperLeftCorner"][0] for md in metadatas.values()) 420 | lrx = max(md["LowerRightCorner"][0] for md in metadatas.values()) 421 | if any_metadata["GeoTransform"][-1] >= 0: 422 | lry = min(md["LowerRightCorner"][1] for md in metadatas.values()) 423 | uly = max(md["UpperLeftCorner"][1] for md in metadatas.values()) 424 | else: 425 | lry = max(md["LowerRightCorner"][1] for md in metadatas.values()) 426 | uly = min(md["UpperLeftCorner"][1] for md in metadatas.values()) 427 | 428 | elif window_rule == "same_as_input": 429 | ulx = metadatas[reference_window_input]["UpperLeftCorner"][0] 430 | lrx = metadatas[reference_window_input]["LowerRightCorner"][0] 431 | lry = metadatas[reference_window_input]["LowerRightCorner"][1] 432 | uly = metadatas[reference_window_input]["UpperLeftCorner"][1] 433 | elif window_rule == "specify": 434 | # When the user explicitly specifies the bounding box -> add some arguments in the function 435 | raise NotImplementedError(window_rule) 436 | elif window_rule == "union": 437 | # When the user wants the final bounding box to be the union of all bounding box 438 | # It should replace any 'outside' pixel by some NoData -> add `fillvalue` argument in the function 439 | raise NotImplementedError(window_rule) 440 | else: 441 | raise ValueError(f'Unknown window_rule "{window_rule}"') 442 | 443 | # Applying this bounding box to all inputs 444 | bounds = (ulx, uly, lrx, lry) 445 | logger.info( 446 | "Cropping all images to extent Upper Left (%s, %s), Lower Right (%s, %s)", 447 | *bounds, 448 | ) 449 | new_inputs = [] 450 | for inp in inputs: 451 | try: 452 | params = { 453 | "in": inp, 454 | "mode": "extent", 455 | "mode.extent.unit": "phy", 456 | "mode.extent.ulx": ulx, 457 | "mode.extent.uly": uly, 458 | "mode.extent.lrx": lrx, 459 | "mode.extent.lry": lry, 460 | } 461 | new_input = App("ExtractROI", params, quiet=True) 462 | new_inputs.append(new_input) 463 | # Potentially update the reference inputs for later resampling 464 | if str(inp) == str(reference_pixel_size_input): 465 | # We use comparison of string because calling '==' 466 | # on pyotb objects implicitly calls BandMathX application, which is not desirable 467 | reference_pixel_size_input = new_input 468 | except RuntimeError as err: 469 | raise ValueError( 470 | f"Cannot define the processing area for input {inp}" 471 | ) from err 472 | inputs = new_inputs 473 | # Update metadatas 474 | metadatas = {input: input.app.GetImageMetaData("out") for input in inputs} 475 | 476 | # Get a metadata of an arbitrary image. This is just to compare later with other images 477 | any_metadata = next(iter(metadatas.values())) 478 | # Handling different pixel sizes 479 | if not all( 480 | md["GeoTransform"][1] == any_metadata["GeoTransform"][1] 481 | and md["GeoTransform"][5] == any_metadata["GeoTransform"][5] 482 | for md in metadatas.values() 483 | ): 484 | # Retrieving the pixel size that will be common for all inputs 485 | if pixel_size_rule == "minimal": 486 | # selecting the input with the smallest x pixel size 487 | reference_input = min( 488 | metadatas, key=lambda x: metadatas[x]["GeoTransform"][1] 489 | ) 490 | if pixel_size_rule == "maximal": 491 | # selecting the input with the highest x pixel size 492 | reference_input = max( 493 | metadatas, key=lambda x: metadatas[x]["GeoTransform"][1] 494 | ) 495 | elif pixel_size_rule == "same_as_input": 496 | reference_input = reference_pixel_size_input 497 | elif pixel_size_rule == "specify": 498 | # When the user explicitly specify the pixel size -> add argument inside the function 499 | raise NotImplementedError(pixel_size_rule) 500 | else: 501 | raise ValueError(f'Unknown pixel_size_rule "{pixel_size_rule}"') 502 | 503 | pixel_size = metadatas[reference_input]["GeoTransform"][1] 504 | 505 | # Perform resampling on inputs that do not comply with the target pixel size 506 | logger.info("Resampling all inputs to resolution: %s", pixel_size) 507 | new_inputs = [] 508 | for inp in inputs: 509 | if metadatas[inp]["GeoTransform"][1] != pixel_size: 510 | superimposed = App( 511 | "Superimpose", 512 | inr=reference_input, 513 | inm=inp, 514 | interpolator=interpolator, 515 | ) 516 | new_inputs.append(superimposed) 517 | else: 518 | new_inputs.append(inp) 519 | inputs = new_inputs 520 | metadatas = {inp: inp.app.GetImageMetaData("out") for inp in inputs} 521 | 522 | # Final superimposition to be sure to have the exact same image sizes 523 | image_sizes = {} 524 | for inp in inputs: 525 | if isinstance(inp, str): 526 | inp = Input(inp) 527 | image_sizes[inp] = inp.shape[:2] 528 | # Selecting the most frequent image size. It will be used as reference. 529 | most_common_image_size, _ = Counter(image_sizes.values()).most_common(1)[0] 530 | same_size_images = [ 531 | inp 532 | for inp, image_size in image_sizes.items() 533 | if image_size == most_common_image_size 534 | ] 535 | 536 | # Superimposition for images that do not have the same size as the others 537 | new_inputs = [] 538 | for inp in inputs: 539 | if image_sizes[inp] != most_common_image_size: 540 | superimposed = App( 541 | "Superimpose", 542 | inr=same_size_images[0], 543 | inm=inp, 544 | interpolator=interpolator, 545 | ) 546 | new_inputs.append(superimposed) 547 | else: 548 | new_inputs.append(inp) 549 | 550 | return new_inputs 551 | -------------------------------------------------------------------------------- /pyotb/helpers.py: -------------------------------------------------------------------------------- 1 | """This module ensure we properly initialize pyotb, or raise SystemExit in case of broken install.""" 2 | 3 | import logging 4 | import logging.config 5 | import os 6 | import sys 7 | import ctypes 8 | import sysconfig 9 | from pathlib import Path 10 | from shutil import which 11 | 12 | # Allow user to switch between OTB directories without setting every env variable 13 | OTB_ROOT = os.environ.get("OTB_ROOT") 14 | DOCS_URL = "https://www.orfeo-toolbox.org/CookBook/Installation.html" 15 | 16 | # Logging 17 | # User can also get logger with `logging.getLogger("pyotb")` 18 | # then use pyotb.set_logger_level() to adjust logger verbosity 19 | 20 | # Search for PYOTB_LOGGER_LEVEL, else use OTB_LOGGER_LEVEL as pyotb level, or fallback to INFO 21 | LOG_LEVEL = ( 22 | os.environ.get("PYOTB_LOGGER_LEVEL") or os.environ.get("OTB_LOGGER_LEVEL") or "INFO" 23 | ) 24 | 25 | logger = logging.getLogger("pyotb") 26 | 27 | logging_cfg = { 28 | "version": 1, 29 | "disable_existing_loggers": False, 30 | "formatters": { 31 | "default": { 32 | "format": "%(asctime)s (%(levelname)-4s) [pyotb] %(message)s", 33 | "datefmt": "%Y-%m-%d %H:%M:%S", 34 | }, 35 | }, 36 | "handlers": { 37 | "stdout": { 38 | "class": "logging.StreamHandler", 39 | "level": "DEBUG", 40 | "formatter": "default", 41 | "stream": "ext://sys.stdout", 42 | } 43 | }, 44 | "loggers": {"pyotb": {"level": LOG_LEVEL, "handlers": ["stdout"]}}, 45 | } 46 | logging.config.dictConfig(logging_cfg) 47 | 48 | 49 | def find_otb(prefix: str = OTB_ROOT, scan: bool = True): 50 | """Try to load OTB bindings or scan system, help user in case of failure, set env. 51 | 52 | If in interactive prompt, user will be asked if he wants to install OTB. 53 | The OTB_ROOT variable allow one to override default OTB version, with auto env setting. 54 | Path precedence : $OTB_ROOT > location of python bindings location 55 | Then, if OTB is not found: 56 | search for releases installations: $HOME/Applications 57 | OR (for Linux): /opt/otbtf > /opt/otb > /usr/local > /usr 58 | OR (for Windows): C:/Program Files 59 | 60 | Args: 61 | prefix: prefix to search OTB in (Default value = OTB_ROOT) 62 | scan: find otb in system known locations (Default value = True) 63 | 64 | Returns: 65 | otbApplication module 66 | 67 | Raises: 68 | SystemError: is OTB is not found (when using interactive mode) 69 | SystemExit: if OTB is not found, since pyotb won't be usable 70 | 71 | """ 72 | otb = None 73 | # Try OTB_ROOT env variable first (allow override default OTB version) 74 | if prefix: 75 | try: 76 | set_environment(prefix) 77 | import otbApplication as otb # pylint: disable=import-outside-toplevel 78 | 79 | return otb 80 | except SystemError as e: 81 | raise SystemExit(f"Failed to import OTB with prefix={prefix}") from e 82 | except ImportError as e: 83 | __suggest_fix_import(str(e), prefix) 84 | raise SystemExit("Failed to import OTB. Exiting.") from e 85 | # Else try import from actual Python path 86 | try: 87 | # Here, we can't properly set env variables before OTB import. 88 | # We assume user did this before running python 89 | # For LD_LIBRARY_PATH problems, use OTB_ROOT instead of PYTHONPATH 90 | import otbApplication as otb # pylint: disable=import-outside-toplevel 91 | 92 | if "OTB_APPLICATION_PATH" not in os.environ: 93 | lib_dir = __find_lib(otb_module=otb) 94 | apps_path = __find_apps_path(lib_dir) 95 | otb.Registry.SetApplicationPath(apps_path) 96 | return otb 97 | except ImportError as e: 98 | pythonpath = os.environ.get("PYTHONPATH") 99 | if not scan: 100 | raise SystemExit( 101 | f"Failed to import OTB with env PYTHONPATH={pythonpath}" 102 | ) from e 103 | # Else search system 104 | logger.info("Failed to import OTB. Searching for it...") 105 | prefix = __find_otb_root() 106 | # Try auto install if shell is interactive 107 | if not prefix and hasattr(sys, "ps1"): 108 | raise SystemError("OTB libraries not found on disk. ") 109 | if not prefix: 110 | raise SystemExit( 111 | "OTB libraries not found on disk. " 112 | "To install it, open an interactive python shell and 'import pyotb'" 113 | ) 114 | # If OTB was found on disk, set env and try to import one last time 115 | try: 116 | set_environment(prefix) 117 | import otbApplication as otb # pylint: disable=import-outside-toplevel 118 | 119 | return otb 120 | except SystemError as e: 121 | raise SystemExit("Auto setup for OTB env failed. Exiting.") from e 122 | # Help user to fix this 123 | except ImportError as e: 124 | __suggest_fix_import(str(e), prefix) 125 | raise SystemExit("Failed to import OTB. Exiting.") from e 126 | 127 | 128 | def set_environment(prefix: str): 129 | """Set environment variables (before OTB import), raise error if anything is wrong. 130 | 131 | Args: 132 | prefix: path to OTB root directory 133 | 134 | Raises: 135 | SystemError: if OTB or GDAL is not found 136 | 137 | """ 138 | logger.info("Preparing environment for OTB in %s", prefix) 139 | # OTB root directory 140 | prefix = Path(prefix) 141 | if not prefix.exists(): 142 | raise FileNotFoundError(str(prefix)) 143 | # External libraries 144 | lib_dir = __find_lib(prefix) 145 | if not lib_dir: 146 | raise SystemError("Can't find OTB external libraries") 147 | # Manually load libraries since we cannot set LD_LIBRARY_PATH in a running process 148 | lib_ext = "dll" if sys.platform == "win32" else "so" 149 | for lib in lib_dir.glob(f"*.{lib_ext}"): 150 | ctypes.CDLL(str(lib)) 151 | 152 | # Add python bindings directory first in PYTHONPATH 153 | otb_api = __find_python_api(lib_dir) 154 | if not otb_api: 155 | raise SystemError("Can't find OTB Python API") 156 | if otb_api not in sys.path: 157 | sys.path.insert(0, otb_api) 158 | 159 | # Add /bin first in PATH, in order to avoid conflicts with another GDAL install 160 | os.environ["PATH"] = f"{prefix / 'bin'}{os.pathsep}{os.environ['PATH']}" 161 | # Ensure APPLICATION_PATH is set 162 | apps_path = __find_apps_path(lib_dir) 163 | if Path(apps_path).exists(): 164 | os.environ["OTB_APPLICATION_PATH"] = apps_path 165 | else: 166 | raise SystemError("Can't find OTB applications directory") 167 | os.environ["LC_NUMERIC"] = "C" 168 | os.environ["GDAL_DRIVER_PATH"] = "disable" 169 | 170 | # Find GDAL libs 171 | if (prefix / "share/gdal").exists(): 172 | # Local GDAL (OTB Superbuild, .run, .exe) 173 | gdal_data = str(prefix / "share/gdal") 174 | proj_lib = str(prefix / "share/proj") 175 | elif sys.platform == "linux": 176 | # If installed using apt or built from source with system deps 177 | gdal_data = "/usr/share/gdal" 178 | proj_lib = "/usr/share/proj" 179 | elif sys.platform == "win32": 180 | gdal_data = str(prefix / "share/data") 181 | proj_lib = str(prefix / "share/proj") 182 | else: 183 | raise SystemError( 184 | f"Can't find GDAL location with current OTB prefix '{prefix}' or in /usr" 185 | ) 186 | os.environ["GDAL_DATA"] = gdal_data 187 | os.environ["PROJ_LIB"] = proj_lib 188 | 189 | 190 | def __find_lib(prefix: str = None, otb_module=None): 191 | """Try to find OTB external libraries directory. 192 | 193 | Args: 194 | prefix: try with OTB root directory 195 | otb_module: try with otbApplication library path if found, else None 196 | 197 | Returns: 198 | lib path, or None if not found 199 | 200 | """ 201 | if prefix is not None: 202 | lib_dir = prefix / "lib" 203 | if lib_dir.exists(): 204 | return lib_dir.absolute() 205 | if otb_module is not None: 206 | lib_dir = Path(otb_module.__file__).parent.parent 207 | # Case OTB .run file 208 | if lib_dir.name == "lib": 209 | return lib_dir.absolute() 210 | # Case /usr 211 | lib_dir = lib_dir.parent 212 | if lib_dir.name in ("lib", "x86_64-linux-gnu"): 213 | return lib_dir.absolute() 214 | # Case built from source (/usr/local, /opt/otb, ...) 215 | lib_dir = lib_dir.parent 216 | if lib_dir.name == "lib": 217 | return lib_dir.absolute() 218 | return None 219 | 220 | 221 | def __find_python_api(lib_dir: Path): 222 | """Try to find the python path. 223 | 224 | Args: 225 | prefix: prefix 226 | 227 | Returns: 228 | OTB python API path, or None if not found 229 | 230 | """ 231 | otb_api = lib_dir / "python" 232 | if not otb_api.exists(): 233 | otb_api = lib_dir / "otb/python" 234 | if otb_api.exists(): 235 | return str(otb_api.absolute()) 236 | logger.debug("Failed to find OTB python bindings directory") 237 | return None 238 | 239 | 240 | def __find_apps_path(lib_dir: Path): 241 | """Try to find the OTB applications path. 242 | 243 | Args: 244 | lib_dir: library path 245 | 246 | Returns: 247 | application path, or empty string if not found 248 | 249 | """ 250 | if lib_dir.exists(): 251 | otb_application_path = lib_dir / "otb/applications" 252 | if otb_application_path.exists(): 253 | return str(otb_application_path.absolute()) 254 | # This should not happen, may be with failed builds ? 255 | logger.error("Library directory found but 'applications' is missing") 256 | return "" 257 | 258 | 259 | def __find_otb_root(): 260 | """Search for OTB root directory in well known locations. 261 | 262 | Returns: 263 | str path of the OTB directory, or None if not found 264 | 265 | """ 266 | prefix = None 267 | # Search possible known locations (system scpecific) 268 | if sys.platform == "linux": 269 | possible_locations = ( 270 | "/usr/lib/x86_64-linux-gnu/otb", 271 | "/usr/local/lib/otb/", 272 | "/opt/otb/lib/otb/", 273 | "/opt/otbtf/lib/otb", 274 | ) 275 | for str_path in possible_locations: 276 | path = Path(str_path) 277 | if not path.exists(): 278 | continue 279 | logger.info("Found %s", str_path) 280 | if path.parent.name == "x86_64-linux-gnu": 281 | prefix = path.parent.parent.parent 282 | else: 283 | prefix = path.parent.parent 284 | elif sys.platform == "win32": 285 | for path in sorted(Path("c:/Program Files").glob("**/OTB-*/lib")): 286 | logger.info("Found %s", path.parent) 287 | prefix = path.parent 288 | # Search for pyotb OTB install, or default on macOS 289 | apps = Path.home() / "Applications" 290 | for path in sorted(apps.glob("OTB-*/lib/")): 291 | logger.info("Found %s", path.parent) 292 | prefix = path.parent 293 | # Return latest found prefix (and version), see precedence in find_otb() docstrings 294 | if isinstance(prefix, Path): 295 | return prefix.absolute() 296 | return None 297 | 298 | 299 | def __suggest_fix_import(error_message: str, prefix: str): 300 | """Help user to fix the OTB installation with appropriate log messages.""" 301 | logger.critical("An error occurred while importing OTB Python API") 302 | logger.critical("OTB error message was '%s'", error_message) 303 | # TODO: update for OTB 9 304 | if sys.platform == "win32": 305 | if error_message.startswith("DLL load failed"): 306 | if sys.version_info.minor != 7: 307 | logger.critical( 308 | "You need Python 3.5 (OTB 6.4 to 7.4) or Python 3.7 (since OTB 8)" 309 | ) 310 | else: 311 | logger.critical( 312 | "It seems that your env variables aren't properly set," 313 | " first use 'call otbenv.bat' then try to import pyotb once again" 314 | ) 315 | elif error_message.startswith("libpython3."): 316 | logger.critical( 317 | "It seems like you need to symlink or recompile python bindings" 318 | ) 319 | if ( 320 | sys.executable.startswith("/usr/bin") 321 | and which("ctest") 322 | and which("python3-config") 323 | ): 324 | logger.critical( 325 | "To compile, use 'cd %s ; source otbenv.profile ; " 326 | "ctest -S share/otb/swig/build_wrapping.cmake -VV'", 327 | prefix, 328 | ) 329 | return 330 | logger.critical( 331 | "You may need to install cmake, python3-dev and mesa's libgl" 332 | " in order to recompile python bindings" 333 | ) 334 | expected = int(error_message[11]) 335 | if expected != sys.version_info.minor: 336 | logger.critical( 337 | "Python library version mismatch (OTB expected 3.%s) : " 338 | "a symlink may not work, depending on your python version", 339 | expected, 340 | ) 341 | lib_dir = sysconfig.get_config_var("LIBDIR") 342 | lib = f"{lib_dir}/libpython3.{sys.version_info.minor}.so" 343 | if Path(lib).exists(): 344 | target = f"{prefix}/lib/libpython3.{expected}.so.1.0" 345 | logger.critical("If using OTB>=8.0, try 'ln -sf %s %s'", lib, target) 346 | logger.critical( 347 | "You can verify installation requirements for your OS at %s", DOCS_URL 348 | ) 349 | 350 | 351 | # This part of pyotb is the first imported during __init__ and checks if OTB is found 352 | # If OTB isn't found, a SystemExit is raised to prevent execution of the core module 353 | find_otb() 354 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 65.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pyotb" 7 | description = "Library to enable easy use of the Orfeo ToolBox (OTB) in Python" 8 | authors = [ 9 | { name = "Rémi Cresson", email = "remi.cresson@inrae.fr" }, 10 | { name = "Nicolas Narçon" }, 11 | { name = "Vincent Delbar" }, 12 | ] 13 | requires-python = ">=3.7" 14 | keywords = ["gis", "remote sensing", "otb", "orfeotoolbox", "orfeo toolbox"] 15 | dependencies = ["numpy>=1.16,<2"] 16 | dynamic = ["version"] 17 | readme = "README.md" 18 | license = "Apache-2.0" 19 | license-files = ["LICENSE", "AUTHORS.md"] 20 | classifiers = [ 21 | "Programming Language :: Python :: 3", 22 | "Programming Language :: Python :: 3.7", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Topic :: Scientific/Engineering :: GIS", 29 | "Topic :: Scientific/Engineering :: Image Processing", 30 | "Operating System :: OS Independent", 31 | ] 32 | 33 | [project.optional-dependencies] 34 | dev = [ 35 | "pytest", 36 | "pytest-cov", 37 | "pylint", 38 | "codespell", 39 | "pydocstyle", 40 | "tomli", 41 | "requests", 42 | ] 43 | 44 | [project.urls] 45 | documentation = "https://pyotb.readthedocs.io" 46 | homepage = "https://github.com/orfeotoolbox/pyotb" 47 | repository = "https://forgemia.inra.fr/orfeo-toolbox/pyotb" 48 | 49 | [tool.setuptools] 50 | packages = ["pyotb"] 51 | 52 | [tool.setuptools.dynamic] 53 | version = { attr = "pyotb.__version__" } 54 | 55 | [tool.pylint] 56 | max-line-length = 88 57 | max-module-lines = 2000 58 | good-names = ["x", "y", "i", "j", "k", "e"] 59 | disable = [ 60 | "line-too-long", 61 | "too-many-locals", 62 | "too-many-branches", 63 | "too-many-statements", 64 | "too-many-instance-attributes", 65 | ] 66 | 67 | [tool.pydocstyle] 68 | convention = "google" 69 | 70 | [tool.black] 71 | line-length = 88 72 | 73 | [tool.pytest.ini_options] 74 | minversion = "7.0" 75 | addopts = "--color=yes --cov=pyotb --no-cov-on-fail --cov-report term" 76 | testpaths = ["tests"] 77 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orfeotoolbox/pyotb/d7fec19cbc4e61fdb1356c7872faede9aeac8607/tests/__init__.py -------------------------------------------------------------------------------- /tests/pipeline_summary.json: -------------------------------------------------------------------------------- 1 | { 2 | "SIMPLE": { 3 | "name": "ManageNoData", 4 | "parameters": { 5 | "usenan": false, 6 | "mode": "buildmask", 7 | "mode.buildmask.inv": 1.0, 8 | "mode.buildmask.outv": 0.0, 9 | "in": { 10 | "name": "BandMath", 11 | "parameters": { 12 | "il": [ 13 | { 14 | "name": "OrthoRectification", 15 | "parameters": { 16 | "map": "utm", 17 | "map.utm.zone": 31, 18 | "map.utm.northhem": true, 19 | "outputs.mode": "auto", 20 | "outputs.ulx": 560000.8382510489, 21 | "outputs.uly": 5495732.692593041, 22 | "outputs.sizex": 251, 23 | "outputs.sizey": 304, 24 | "outputs.spacingx": 5.997312290777139, 25 | "outputs.spacingy": -5.997312290777139, 26 | "outputs.lrx": 561506.163636034, 27 | "outputs.lry": 5493909.509656644, 28 | "outputs.isotropic": true, 29 | "interpolator": "bco", 30 | "interpolator.bco.radius": 2, 31 | "opt.rpc": 10, 32 | "opt.gridspacing": 4.0, 33 | "io.in": "/vsicurl/https://gitlab.orfeo-toolbox.org/orfeotoolbox/otb/-/raw/develop/Data/Input/SP67_FR_subset_1.tif" 34 | } 35 | } 36 | ], 37 | "exp": "im1b1" 38 | } 39 | } 40 | } 41 | }, 42 | "DIAMOND": { 43 | "name": "BandMathX", 44 | "parameters": { 45 | "il": [ 46 | { 47 | "name": "OrthoRectification", 48 | "parameters": { 49 | "map": "utm", 50 | "map.utm.zone": 31, 51 | "map.utm.northhem": true, 52 | "outputs.mode": "auto", 53 | "outputs.ulx": 560000.8382510489, 54 | "outputs.uly": 5495732.692593041, 55 | "outputs.sizex": 251, 56 | "outputs.sizey": 304, 57 | "outputs.spacingx": 5.997312290777139, 58 | "outputs.spacingy": -5.997312290777139, 59 | "outputs.lrx": 561506.163636034, 60 | "outputs.lry": 5493909.509656644, 61 | "outputs.isotropic": true, 62 | "interpolator": "bco", 63 | "interpolator.bco.radius": 2, 64 | "opt.rpc": 10, 65 | "opt.gridspacing": 4.0, 66 | "io.in": { 67 | "name": "BandMath", 68 | "parameters": { 69 | "il": [ 70 | "/vsicurl/https://gitlab.orfeo-toolbox.org/orfeotoolbox/otb/-/raw/develop/Data/Input/SP67_FR_subset_1.tif" 71 | ], 72 | "exp": "im1b1" 73 | } 74 | } 75 | } 76 | }, 77 | { 78 | "name": "ManageNoData", 79 | "parameters": { 80 | "usenan": false, 81 | "mode": "buildmask", 82 | "mode.buildmask.inv": 1.0, 83 | "mode.buildmask.outv": 0.0, 84 | "in": { 85 | "name": "OrthoRectification", 86 | "parameters": { 87 | "map": "utm", 88 | "map.utm.zone": 31, 89 | "map.utm.northhem": true, 90 | "outputs.mode": "auto", 91 | "outputs.ulx": 560000.8382510489, 92 | "outputs.uly": 5495732.692593041, 93 | "outputs.sizex": 251, 94 | "outputs.sizey": 304, 95 | "outputs.spacingx": 5.997312290777139, 96 | "outputs.spacingy": -5.997312290777139, 97 | "outputs.lrx": 561506.163636034, 98 | "outputs.lry": 5493909.509656644, 99 | "outputs.isotropic": true, 100 | "interpolator": "bco", 101 | "interpolator.bco.radius": 2, 102 | "opt.rpc": 10, 103 | "opt.gridspacing": 4.0, 104 | "io.in": { 105 | "name": "BandMath", 106 | "parameters": { 107 | "il": [ 108 | "/vsicurl/https://gitlab.orfeo-toolbox.org/orfeotoolbox/otb/-/raw/develop/Data/Input/SP67_FR_subset_1.tif" 109 | ], 110 | "exp": "im1b1" 111 | } 112 | } 113 | } 114 | } 115 | } 116 | } 117 | ], 118 | "exp": "im1+im2" 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import numpy as np 3 | 4 | import pyotb 5 | from .tests_data import * 6 | 7 | 8 | def test_app_parameters(): 9 | # Input / ExtractROI 10 | assert INPUT.parameters 11 | assert (INPUT.parameters["sizex"], INPUT.parameters["sizey"]) == (251, 304) 12 | # OrthoRectification 13 | app = pyotb.OrthoRectification(INPUT) 14 | assert isinstance(app.parameters["map"], str) 15 | assert app.parameters["map"] == "utm" 16 | assert "map" in app._auto_parameters 17 | app.set_parameters({"map": "epsg", "map.epsg.code": 2154}) 18 | assert app.parameters["map"] == "epsg" 19 | assert "map" in app._settings and "map" not in app._auto_parameters 20 | assert app.parameters["map.epsg.code"] == app.app.GetParameters()["map.epsg.code"] 21 | # Orthorectification with underscore kwargs 22 | app = pyotb.OrthoRectification(io_in=INPUT, map_epsg_code=2154) 23 | assert app.parameters["map.epsg.code"] == 2154 24 | # ManageNoData 25 | app = pyotb.ManageNoData(INPUT) 26 | assert "usenan" in app._auto_parameters 27 | assert "mode.buildmask.inv" in app._auto_parameters 28 | # OpticalCalibration 29 | app = pyotb.OpticalCalibration(pyotb.Input(PLEIADES_IMG_URL), level="toa") 30 | assert "milli" in app._auto_parameters 31 | assert "clamp" in app._auto_parameters 32 | assert app._auto_parameters["acqui.year"] == 2012 33 | assert app._auto_parameters["acqui.sun.elev"] == 23.836299896240234 34 | 35 | 36 | def test_app_properties(): 37 | assert INPUT.input_key == INPUT.input_image_key == "in" 38 | assert INPUT.output_key == INPUT.output_image_key == "out" 39 | with pytest.raises(KeyError): 40 | pyotb.BandMath(INPUT, expression="im1b1") 41 | # Test user can set custom name 42 | app = pyotb.App("BandMath", [INPUT], exp="im1b1", name="TestName") 43 | assert app.name == "TestName" 44 | # Test data dict is not empty 45 | app = pyotb.ReadImageInfo(INPUT) 46 | assert app.data 47 | # Test elapsed time is not null 48 | assert 0 < app.elapsed_time < 1 49 | 50 | 51 | def test_app_input_vsi(): 52 | # Ensure old way is still working: ExtractROI will raise RuntimeError if a path is malformed 53 | pyotb.Input("/vsicurl/" + SPOT_IMG_URL) 54 | # Simple remote file 55 | info = pyotb.ReadImageInfo("https://fake.com/image.tif", frozen=True) 56 | assert ( 57 | info.app.GetParameterValue("in") 58 | == info.parameters["in"] 59 | == "/vsicurl/https://fake.com/image.tif" 60 | ) 61 | # Compressed single file archive 62 | info = pyotb.ReadImageInfo("image.tif.zip", frozen=True) 63 | assert ( 64 | info.app.GetParameterValue("in") 65 | == info.parameters["in"] 66 | == "/vsizip/image.tif.zip" 67 | ) 68 | # File within compressed remote archive 69 | info = pyotb.ReadImageInfo("https://fake.com/archive.tar.gz/image.tif", frozen=True) 70 | assert ( 71 | info.app.GetParameterValue("in") 72 | == info.parameters["in"] 73 | == "/vsitar//vsicurl/https://fake.com/archive.tar.gz/image.tif" 74 | ) 75 | # Piped curl --> zip --> tiff 76 | ziped_tif_urls = ( 77 | "https://github.com/OSGeo/gdal/raw/master" 78 | "/autotest/gcore/data/byte.tif.zip", # without /vsi 79 | "/vsizip/vsicurl/https://github.com/OSGeo/gdal/raw/master" 80 | "/autotest/gcore/data/byte.tif.zip", # with /vsi 81 | ) 82 | for ziped_tif_url in ziped_tif_urls: 83 | info = pyotb.ReadImageInfo(ziped_tif_url) 84 | assert info["sizex"] == 20 85 | 86 | 87 | def test_img_properties(): 88 | assert INPUT.dtype == "uint8" 89 | assert INPUT.shape == (304, 251, 4) 90 | assert INPUT.transform == (6.0, 0.0, 760056.0, 0.0, -6.0, 6946092.0) 91 | with pytest.raises(TypeError): 92 | assert pyotb.ReadImageInfo(INPUT).dtype == "uint8" 93 | 94 | 95 | def test_img_metadata(): 96 | assert "ProjectionRef" in INPUT.metadata 97 | assert "TIFFTAG_SOFTWARE" in INPUT.metadata 98 | inp2 = pyotb.Input( 99 | "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/" 100 | "47/Q/RU/2021/12/S2B_47QRU_20211227_0_L2A/B04.tif" 101 | ) 102 | assert "ProjectionRef" in inp2.metadata 103 | assert "OVR_RESAMPLING_ALG" in inp2.metadata 104 | # Metadata with numeric values (e.g. TileHintX) 105 | fp = ( 106 | "https://gitlab.orfeo-toolbox.org/orfeotoolbox/otb/-/raw/develop/" 107 | "Data/Input/radarsat2/RADARSAT2_ALTONA_300_300_VV.tif?inline=false" 108 | ) 109 | app = pyotb.BandMath({"il": [fp], "exp": "im1b1"}) 110 | assert "TileHintX" in app.metadata 111 | 112 | 113 | def test_essential_apps(): 114 | readimageinfo = pyotb.ReadImageInfo(INPUT, quiet=True) 115 | assert (readimageinfo["sizex"], readimageinfo["sizey"]) == (251, 304) 116 | assert readimageinfo["numberbands"] == 4 117 | computeimagestats = pyotb.ComputeImagesStatistics([INPUT], quiet=True) 118 | assert computeimagestats["out.min"] == TEST_IMAGE_STATS["out.min"] 119 | slicer_computeimagestats = pyotb.ComputeImagesStatistics( 120 | il=[INPUT[:10, :10, 0]], quiet=True 121 | ) 122 | assert slicer_computeimagestats["out.min"] == [180] 123 | 124 | 125 | def test_get_statistics(): 126 | stats_data = pyotb.ComputeImagesStatistics(INPUT).data 127 | assert stats_data == TEST_IMAGE_STATS 128 | assert INPUT.get_statistics() == TEST_IMAGE_STATS 129 | 130 | 131 | def test_get_info(): 132 | infos = INPUT.get_info() 133 | assert (infos["sizex"], infos["sizey"]) == (251, 304) 134 | bm_infos = pyotb.BandMathX([INPUT], exp="im1")["out"].get_info() 135 | assert infos == bm_infos 136 | 137 | 138 | def test_read_values_at_coords(): 139 | assert INPUT[0, 0, 0] == 180 140 | assert INPUT[10, 20, :] == [207, 192, 172, 255] 141 | 142 | 143 | def test_xy_to_rowcol(): 144 | assert INPUT.get_rowcol_from_xy(760101, 6945977) == (19, 7) 145 | 146 | 147 | def test_write(): 148 | # Write string filepath 149 | assert INPUT.write("/dev/shm/test_write.tif") 150 | INPUT["out"].filepath.unlink() 151 | # With Path filepath 152 | assert INPUT.write(Path("/dev/shm/test_write.tif")) 153 | INPUT["out"].filepath.unlink() 154 | # Write to uint8 155 | assert INPUT.write(Path("/dev/shm/test_write.tif"), pixel_type="uint8") 156 | assert INPUT["out"].dtype == "uint8" 157 | INPUT["out"].filepath.unlink() 158 | # Write frozen app 159 | frozen_app = pyotb.BandMath(INPUT, exp="im1b1", frozen=True) 160 | assert frozen_app.write("/dev/shm/test_frozen_app_write.tif") 161 | frozen_app["out"].filepath.unlink() 162 | frozen_app_init_with_outfile = pyotb.BandMath( 163 | INPUT, exp="im1b1", out="/dev/shm/test_frozen_app_write.tif", frozen=True 164 | ) 165 | assert frozen_app_init_with_outfile.write(pixel_type="uint16") 166 | assert frozen_app_init_with_outfile.dtype == "uint16" 167 | frozen_app_init_with_outfile["out"].filepath.unlink() 168 | 169 | 170 | def test_write_multi_output(): 171 | mss = pyotb.MeanShiftSmoothing( 172 | SPOT_IMG_URL, 173 | fout="/dev/shm/test_ext_fn_fout.tif", 174 | foutpos="/dev/shm/test_ext_fn_foutpos.tif", 175 | ) 176 | 177 | mss = pyotb.MeanShiftSmoothing(SPOT_IMG_URL) 178 | assert mss.write( 179 | { 180 | "fout": "/dev/shm/test_ext_fn_fout.tif", 181 | "foutpos": "/dev/shm/test_ext_fn_foutpos.tif", 182 | }, 183 | ext_fname={"nodata": 0, "gdal:co:COMPRESS": "DEFLATE"}, 184 | ) 185 | 186 | dr = pyotb.DimensionalityReduction( 187 | SPOT_IMG_URL, out="/dev/shm/1.tif", outinv="/dev/shm/2.tif" 188 | ) 189 | dr = pyotb.DimensionalityReduction(SPOT_IMG_URL) 190 | assert dr.write( 191 | {"out": "/dev/shm/1.tif", "outinv": "/dev/shm/2.tif"} 192 | ) 193 | 194 | 195 | def test_write_ext_fname(): 196 | def _check(expected: str, key: str = "out", app=INPUT.app): 197 | fn = app.GetParameterString(key) 198 | assert "?&" in fn 199 | assert fn.split("?&", 1)[1] == expected 200 | 201 | assert INPUT.write("/dev/shm/test_write.tif", ext_fname="nodata=0") 202 | _check("nodata=0") 203 | assert INPUT.write("/dev/shm/test_write.tif", ext_fname={"nodata": "0"}) 204 | _check("nodata=0") 205 | assert INPUT.write("/dev/shm/test_write.tif", ext_fname={"nodata": 0}) 206 | _check("nodata=0") 207 | assert INPUT.write( 208 | "/dev/shm/test_write.tif", 209 | ext_fname={"nodata": 0, "gdal:co:COMPRESS": "DEFLATE"}, 210 | ) 211 | _check("nodata=0&gdal:co:COMPRESS=DEFLATE") 212 | assert INPUT.write( 213 | "/dev/shm/test_write.tif", ext_fname="nodata=0&gdal:co:COMPRESS=DEFLATE" 214 | ) 215 | _check("nodata=0&gdal:co:COMPRESS=DEFLATE") 216 | assert INPUT.write( 217 | "/dev/shm/test_write.tif?&box=0:0:10:10", 218 | ext_fname={"nodata": "0", "gdal:co:COMPRESS": "DEFLATE", "box": "0:0:20:20"}, 219 | ) 220 | # Check that the bbox is the one specified in the filepath, not the one 221 | # specified in `ext_filename` 222 | _check("nodata=0&gdal:co:COMPRESS=DEFLATE&box=0:0:10:10") 223 | assert INPUT.write( 224 | "/dev/shm/test_write.tif?&box=0:0:10:10", 225 | ext_fname="nodata=0&gdal:co:COMPRESS=DEFLATE&box=0:0:20:20", 226 | ) 227 | _check("nodata=0&gdal:co:COMPRESS=DEFLATE&box=0:0:10:10") 228 | INPUT["out"].filepath.unlink() 229 | 230 | mmsd = pyotb.MorphologicalMultiScaleDecomposition(INPUT) 231 | mmsd.write( 232 | { 233 | "outconvex": "/dev/shm/outconvex.tif?&nodata=1", 234 | "outconcave": "/dev/shm/outconcave.tif?&nodata=2", 235 | "outleveling": "/dev/shm/outleveling.tif?&nodata=3", 236 | }, 237 | ext_fname={"nodata": 0, "gdal:co:COMPRESS": "DEFLATE"}, 238 | ) 239 | _check("nodata=1&gdal:co:COMPRESS=DEFLATE", key="outconvex", app=mmsd.app) 240 | _check("nodata=2&gdal:co:COMPRESS=DEFLATE", key="outconcave", app=mmsd.app) 241 | _check("nodata=3&gdal:co:COMPRESS=DEFLATE", key="outleveling", app=mmsd.app) 242 | mmsd["outconvex"].filepath.unlink() 243 | mmsd["outconcave"].filepath.unlink() 244 | mmsd["outleveling"].filepath.unlink() 245 | 246 | 247 | def test_output(): 248 | assert INPUT["out"].write("/dev/shm/test_output_write.tif") 249 | INPUT["out"].filepath.unlink() 250 | frozen_app = pyotb.BandMath(INPUT, exp="im1b1", frozen=True) 251 | assert frozen_app["out"].write("/dev/shm/test_frozen_app_write.tif") 252 | frozen_app["out"].filepath.unlink() 253 | info_from_output_obj = pyotb.ReadImageInfo(INPUT["out"]) 254 | assert info_from_output_obj.data 255 | 256 | 257 | # Slicer 258 | def test_slicer(): 259 | sliced = INPUT[:50, :60, :3] 260 | assert sliced.parameters["cl"] == ["Channel1", "Channel2", "Channel3"] 261 | assert sliced.shape == (50, 60, 3) 262 | assert sliced.dtype == "uint8" 263 | sliced_negative_band_idx = INPUT[:50, :60, :-2] 264 | assert sliced_negative_band_idx.shape == (50, 60, 2) 265 | sliced_from_output = pyotb.BandMath([INPUT], exp="im1b1")["out"][:50, :60, :-2] 266 | assert isinstance(sliced_from_output, pyotb.core.Slicer) 267 | 268 | 269 | # Operation and LogicalOperation 270 | def test_operator_expressions(): 271 | op = INPUT / 255 * 128 272 | assert ( 273 | op.exp 274 | == "((im1b1 / 255) * 128);((im1b2 / 255) * 128);((im1b3 / 255) * 128);((im1b4 / 255) * 128)" 275 | ) 276 | assert op.dtype == "float32" 277 | assert abs(INPUT).exp == "(abs(im1b1));(abs(im1b2));(abs(im1b3));(abs(im1b4))" 278 | summed_bands = sum(INPUT[:, :, b] for b in range(INPUT.shape[-1])) 279 | assert summed_bands.exp == "((((0 + im1b1) + im1b2) + im1b3) + im1b4)" 280 | 281 | 282 | def operation_test(func, exp): 283 | meas = func(INPUT) 284 | ref = pyotb.BandMathX({"il": [SPOT_IMG_URL], "exp": exp}) 285 | for i in range(1, 5): 286 | compared = pyotb.CompareImages( 287 | {"ref.in": ref, "meas.in": meas, "ref.channel": i, "meas.channel": i} 288 | ) 289 | assert (compared["count"], compared["mse"]) == (0, 0) 290 | 291 | 292 | def test_operation_add(): 293 | operation_test(lambda x: x + x, "im1 + im1") 294 | operation_test(lambda x: x + INPUT, "im1 + im1") 295 | operation_test(lambda x: INPUT + x, "im1 + im1") 296 | operation_test(lambda x: x + 2, "im1 + {2;2;2;2}") 297 | operation_test(lambda x: x + 2.0, "im1 + {2.0;2.0;2.0;2.0}") 298 | operation_test(lambda x: 2 + x, "{2;2;2;2} + im1") 299 | operation_test(lambda x: 2.0 + x, "{2.0;2.0;2.0;2.0} + im1") 300 | 301 | 302 | def test_operation_sub(): 303 | operation_test(lambda x: x - x, "im1 - im1") 304 | operation_test(lambda x: x - INPUT, "im1 - im1") 305 | operation_test(lambda x: INPUT - x, "im1 - im1") 306 | operation_test(lambda x: x - 2, "im1 - {2;2;2;2}") 307 | operation_test(lambda x: x - 2.0, "im1 - {2.0;2.0;2.0;2.0}") 308 | operation_test(lambda x: 2 - x, "{2;2;2;2} - im1") 309 | operation_test(lambda x: 2.0 - x, "{2.0;2.0;2.0;2.0} - im1") 310 | 311 | 312 | def test_operation_mult(): 313 | operation_test(lambda x: x * x, "im1 mult im1") 314 | operation_test(lambda x: x * INPUT, "im1 mult im1") 315 | operation_test(lambda x: INPUT * x, "im1 mult im1") 316 | operation_test(lambda x: x * 2, "im1 * 2") 317 | operation_test(lambda x: x * 2.0, "im1 * 2.0") 318 | operation_test(lambda x: 2 * x, "2 * im1") 319 | operation_test(lambda x: 2.0 * x, "2.0 * im1") 320 | 321 | 322 | def test_operation_div(): 323 | operation_test(lambda x: x / x, "im1 div im1") 324 | operation_test(lambda x: x / INPUT, "im1 div im1") 325 | operation_test(lambda x: INPUT / x, "im1 div im1") 326 | operation_test(lambda x: x / 2, "im1 * 0.5") 327 | operation_test(lambda x: x / 2.0, "im1 * 0.5") 328 | operation_test(lambda x: 2 / x, "{2;2;2;2} div im1") 329 | operation_test(lambda x: 2.0 / x, "{2.0;2.0;2.0;2.0} div im1") 330 | 331 | 332 | # BandMath NDVI == RadiometricIndices NDVI ? 333 | def test_ndvi_comparison(): 334 | ndvi_bandmath = (INPUT[:, :, -1] - INPUT[:, :, [0]]) / ( 335 | INPUT[:, :, -1] + INPUT[:, :, 0] 336 | ) 337 | ndvi_indices = pyotb.RadiometricIndices( 338 | INPUT, {"list": ["Vegetation:NDVI"], "channels.red": 1, "channels.nir": 4} 339 | ) 340 | assert ndvi_bandmath.exp == "((im1b4 - im1b1) / (im1b4 + im1b1))" 341 | assert ndvi_bandmath.write("/dev/shm/ndvi_bandmath.tif", "float") 342 | assert ndvi_indices.write("/dev/shm/ndvi_indices.tif", "float") 343 | 344 | compared = pyotb.CompareImages( 345 | {"ref.in": ndvi_indices, "meas.in": "/dev/shm/ndvi_bandmath.tif"} 346 | ) 347 | assert (compared["count"], compared["mse"]) == (0, 0) 348 | thresholded_indices = pyotb.where(ndvi_indices >= 0.3, 1, 0) 349 | assert thresholded_indices["exp"] == "((im1b1 >= 0.3) ? 1 : 0)" 350 | thresholded_bandmath = pyotb.where(ndvi_bandmath >= 0.3, 1, 0) 351 | assert ( 352 | thresholded_bandmath["exp"] 353 | == "((((im1b4 - im1b1) / (im1b4 + im1b1)) >= 0.3) ? 1 : 0)" 354 | ) 355 | 356 | 357 | # Tests for functions.py 358 | def test_binary_mask_where(): 359 | # Create binary mask based on several possible values 360 | values = [1, 2, 3, 4] 361 | res = pyotb.where(pyotb.any(INPUT[:, :, 0] == value for value in values), 255, 0) 362 | assert ( 363 | res.exp 364 | == "(((((im1b1 == 1) || (im1b1 == 2)) || (im1b1 == 3)) || (im1b1 == 4)) ? 255 : 0)" 365 | ) 366 | 367 | 368 | # Tests for summarize() 369 | def test_summarize_pipeline_simple(): 370 | app1 = pyotb.OrthoRectification({"io.in": SPOT_IMG_URL}) 371 | app2 = pyotb.BandMath({"il": [app1], "exp": "im1b1"}) 372 | app3 = pyotb.ManageNoData({"in": app2}) 373 | summary = pyotb.summarize(app3) 374 | assert SIMPLE_SERIALIZATION == summary 375 | 376 | 377 | def test_summarize_pipeline_diamond(): 378 | app1 = pyotb.BandMath({"il": [SPOT_IMG_URL], "exp": "im1b1"}) 379 | app2 = pyotb.OrthoRectification({"io.in": app1}) 380 | app3 = pyotb.ManageNoData({"in": app2}) 381 | app4 = pyotb.BandMathX({"il": [app2, app3], "exp": "im1+im2"}) 382 | summary = pyotb.summarize(app4) 383 | assert DIAMOND_SERIALIZATION == summary 384 | 385 | 386 | def test_summarize_output_obj(): 387 | assert pyotb.summarize(INPUT["out"]) 388 | 389 | 390 | def test_summarize_strip_output(): 391 | in_fn = "/vsicurl/" + SPOT_IMG_URL 392 | in_fn_w_ext = "/vsicurl/" + SPOT_IMG_URL + "?&skipcarto=1" 393 | out_fn = "/dev/shm/output.tif" 394 | out_fn_w_ext = out_fn + "?&box=10:10:10:10" 395 | 396 | baseline = [ 397 | (in_fn, out_fn_w_ext, "out", {}, out_fn_w_ext), 398 | (in_fn, out_fn_w_ext, "out", {"strip_outpath": True}, out_fn), 399 | (in_fn_w_ext, out_fn, "in", {}, in_fn_w_ext), 400 | (in_fn_w_ext, out_fn, "in", {"strip_inpath": True}, in_fn), 401 | ] 402 | 403 | for inp, out, key, extra_args, expected in baseline: 404 | app = pyotb.ExtractROI({"in": inp, "out": out}) 405 | summary = pyotb.summarize(app, **extra_args) 406 | assert ( 407 | summary["parameters"][key] == expected 408 | ), f"Failed for input {inp}, output {out}, args {extra_args}" 409 | 410 | 411 | def test_summarize_consistency(): 412 | app_fns = [ 413 | lambda inp: pyotb.ExtractROI( 414 | {"in": inp, "startx": 10, "starty": 10, "sizex": 50, "sizey": 50} 415 | ), 416 | lambda inp: pyotb.ManageNoData({"in": inp, "mode": "changevalue"}), 417 | lambda inp: pyotb.DynamicConvert({"in": inp}), 418 | lambda inp: pyotb.Mosaic({"il": [inp]}), 419 | lambda inp: pyotb.BandMath({"il": [inp], "exp": "im1b1 + 1"}), 420 | lambda inp: pyotb.BandMathX({"il": [inp], "exp": "im1"}), 421 | lambda inp: pyotb.OrthoRectification({"io.in": inp}), 422 | ] 423 | 424 | def operator_test(app_fn): 425 | """ 426 | Here we create 2 summaries: 427 | - summary of the app before write() 428 | - summary of the app after write() 429 | Then we check that both only differ with the output parameter 430 | """ 431 | app = app_fn(inp=SPOT_IMG_URL) 432 | out_file = "/dev/shm/out.tif" 433 | out_key = app.output_image_key 434 | summary_wo_wrt = pyotb.summarize(app) 435 | app.write(out_file) 436 | summay_w_wrt = pyotb.summarize(app) 437 | app[out_key].filepath.unlink() 438 | summary_wo_wrt["parameters"].update({out_key: out_file}) 439 | assert summary_wo_wrt == summay_w_wrt 440 | 441 | for app_fn in app_fns: 442 | operator_test(app_fn) 443 | 444 | 445 | # Numpy tests 446 | def test_numpy_exports_dic(): 447 | INPUT.export() 448 | exported_array = INPUT.exports_dic[INPUT.output_image_key]["array"] 449 | assert isinstance(exported_array, np.ndarray) 450 | assert exported_array.dtype == "uint8" 451 | del INPUT.exports_dic["out"] 452 | INPUT["out"].export() 453 | assert INPUT["out"].output_image_key in INPUT["out"].exports_dic 454 | 455 | 456 | def test_numpy_conversions(): 457 | array = INPUT.to_numpy() 458 | assert array.dtype == np.uint8 459 | assert array.shape == INPUT.shape 460 | assert (array.min(), array.max()) == (33, 255) 461 | # Sliced img to array 462 | sliced = INPUT[:100, :200, :3] 463 | sliced_array = sliced.to_numpy() 464 | assert sliced_array.dtype == np.uint8 465 | assert sliced_array.shape == (100, 200, 3) 466 | # Test auto convert to numpy 467 | assert isinstance(np.array(INPUT), np.ndarray) 468 | assert INPUT.shape == np.array(INPUT).shape 469 | assert INPUT[19, 7] == list(INPUT.to_numpy()[19, 7]) 470 | # Add noise test from the docs 471 | white_noise = np.random.normal(0, 50, size=INPUT.shape) 472 | noisy_image = INPUT + white_noise 473 | assert isinstance(noisy_image, pyotb.core.App) 474 | assert noisy_image.shape == INPUT.shape 475 | 476 | 477 | def test_numpy_to_rasterio(): 478 | array, profile = INPUT.to_rasterio() 479 | assert array.dtype == profile["dtype"] == np.uint8 480 | assert array.shape == (4, 304, 251) 481 | assert profile["transform"] == (6.0, 0.0, 760056.0, 0.0, -6.0, 6946092.0) 482 | 483 | # CRS test requires GDAL python bindings 484 | try: 485 | from osgeo import osr 486 | 487 | crs = osr.SpatialReference() 488 | crs.ImportFromEPSG(2154) 489 | dest_crs = osr.SpatialReference() 490 | dest_crs.ImportFromWkt(profile["crs"]) 491 | assert dest_crs.IsSame(crs) 492 | except ImportError: 493 | pass 494 | -------------------------------------------------------------------------------- /tests/test_pipeline.py: -------------------------------------------------------------------------------- 1 | import os 2 | import itertools 3 | import pytest 4 | import pyotb 5 | from .tests_data import INPUT, SPOT_IMG_URL 6 | 7 | 8 | # List of buildings blocks, we can add other pyotb objects here 9 | OTBAPPS_BLOCKS = [ 10 | # lambda inp: pyotb.ExtractROI({"in": inp, "startx": 10, "starty": 10, "sizex": 50, "sizey": 50}), 11 | lambda inp: pyotb.ManageNoData({"in": inp, "mode": "changevalue"}), 12 | lambda inp: pyotb.DynamicConvert({"in": inp}), 13 | lambda inp: pyotb.Mosaic({"il": [inp]}), 14 | lambda inp: pyotb.BandMath({"il": [inp], "exp": "im1b1 + 1"}), 15 | lambda inp: pyotb.BandMathX({"il": [inp], "exp": "im1"}), 16 | ] 17 | 18 | PYOTB_BLOCKS = [ 19 | lambda inp: 1 / (1 + abs(inp) * 2), 20 | lambda inp: inp[:80, 10:60, :], 21 | ] 22 | PIPELINES_LENGTH = [1, 2, 3] 23 | 24 | ALL_BLOCKS = PYOTB_BLOCKS + OTBAPPS_BLOCKS 25 | 26 | 27 | def generate_pipeline(inp, building_blocks): 28 | """ 29 | Create pipeline formed with the given building blocks 30 | 31 | Args: 32 | inp: input 33 | building_blocks: building blocks 34 | 35 | Returns: 36 | pipeline 37 | 38 | """ 39 | # Create the N apps pipeline 40 | pipeline = [] 41 | for app in building_blocks: 42 | new_app_inp = pipeline[-1] if pipeline else inp 43 | new_app = app(new_app_inp) 44 | pipeline.append(new_app) 45 | return pipeline 46 | 47 | 48 | def combi(building_blocks, length): 49 | """Returns all possible combinations of N unique buildings blocks 50 | 51 | Args: 52 | building_blocks: building blocks 53 | length: length 54 | 55 | Returns: 56 | list of combinations 57 | 58 | """ 59 | av = list(itertools.combinations(building_blocks, length)) 60 | al = [] 61 | for a in av: 62 | al += itertools.permutations(a) 63 | 64 | return list(set(al)) 65 | 66 | 67 | def pipeline2str(pipeline): 68 | """Prints the pipeline blocks 69 | 70 | Args: 71 | pipeline: pipeline 72 | 73 | Returns: 74 | a string 75 | 76 | """ 77 | return " > ".join( 78 | [INPUT.__class__.__name__] 79 | + [f"{i}.{app.name.split()[0]}" for i, app in enumerate(pipeline)] 80 | ) 81 | 82 | 83 | def make_pipelines_list(): 84 | """Create a list of pipelines using different lengths and blocks""" 85 | blocks = { 86 | SPOT_IMG_URL: OTBAPPS_BLOCKS, 87 | INPUT: ALL_BLOCKS, 88 | } # for filepath, we can't use Slicer or Operation 89 | pipelines = [] 90 | names = [] 91 | for inp, blocks in blocks.items(): 92 | # Generate pipelines of different length 93 | for length in PIPELINES_LENGTH: 94 | blocks_combis = combi(building_blocks=blocks, length=length) 95 | for block in blocks_combis: 96 | pipe = generate_pipeline(inp, block) 97 | name = pipeline2str(pipe) 98 | if name not in names: 99 | pipelines.append(pipe) 100 | names.append(name) 101 | 102 | return pipelines, names 103 | 104 | 105 | def shape(pipe): 106 | for app in pipe: 107 | yield bool(app.shape) 108 | 109 | 110 | def shape_nointermediate(pipe): 111 | app = [pipe[-1]][0] 112 | yield bool(app.shape) 113 | 114 | 115 | def shape_backward(pipe): 116 | for app in reversed(pipe): 117 | yield bool(app.shape) 118 | 119 | 120 | def write(pipe): 121 | for i, app in enumerate(pipe): 122 | out = f"/tmp/out_{i}.tif" 123 | if os.path.isfile(out): 124 | os.remove(out) 125 | yield app.write(out) 126 | 127 | 128 | def write_nointermediate(pipe): 129 | app = [pipe[-1]][0] 130 | out = "/tmp/out_0.tif" 131 | if os.path.isfile(out): 132 | os.remove(out) 133 | yield app.write(out) 134 | 135 | 136 | def write_backward(pipe): 137 | for i, app in enumerate(reversed(pipe)): 138 | out = f"/tmp/out_{i}.tif" 139 | if os.path.isfile(out): 140 | os.remove(out) 141 | yield app.write(out) 142 | 143 | 144 | funcs = [ 145 | shape, 146 | shape_nointermediate, 147 | shape_backward, 148 | write, 149 | write_nointermediate, 150 | write_backward, 151 | ] 152 | 153 | PIPELINES, NAMES = make_pipelines_list() 154 | 155 | 156 | @pytest.mark.parametrize("test_func", funcs) 157 | def test(test_func): 158 | fname = test_func.__name__ 159 | successes, failures = 0, 0 160 | total_successes = [] 161 | for pipeline, blocks in zip(PIPELINES, NAMES): 162 | err = None 163 | try: 164 | # Here we count non-empty shapes or write results, no errors during exec 165 | bool_tests = list(test_func(pipeline)) 166 | overall_success = all(bool_tests) 167 | except Exception as e: 168 | # Unexpected exception in the pipeline, e.g. a RuntimeError we missed 169 | bool_tests = [] 170 | overall_success = False 171 | err = e 172 | if overall_success: 173 | print(f"\033[92m{fname}: success with [{blocks}]\033[0m\n") 174 | successes += 1 175 | else: 176 | print(f"\033[91m{fname}: failure with [{blocks}]\033[0m {bool_tests}\n") 177 | if err: 178 | print(f"exception thrown: {err}") 179 | failures += 1 180 | total_successes.append(overall_success) 181 | print(f"\nEnded test {fname} with {successes} successes, {failures} failures") 182 | if err: 183 | raise err 184 | assert all(total_successes) 185 | -------------------------------------------------------------------------------- /tests/tests_data.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | import pyotb 4 | 5 | 6 | SPOT_IMG_URL = "https://gitlab.orfeo-toolbox.org/orfeotoolbox/otb/-/raw/develop/Data/Input/SP67_FR_subset_1.tif" 7 | PLEIADES_IMG_URL = "https://gitlab.orfeo-toolbox.org/orfeotoolbox/otb/-/raw/develop/Data/Baseline/OTB/Images/prTvOrthoRectification_pleiades-1_noDEM.tif" 8 | INPUT = pyotb.Input(SPOT_IMG_URL) 9 | 10 | 11 | TEST_IMAGE_STATS = { 12 | "out.mean": [79.5505, 109.225, 115.456, 249.349], 13 | "out.min": [33, 64, 91, 47], 14 | "out.max": [255, 255, 230, 255], 15 | "out.std": [51.0754, 35.3152, 23.4514, 20.3827], 16 | } 17 | 18 | json_file = Path(__file__).parent / "pipeline_summary.json" 19 | with json_file.open("r", encoding="utf-8") as js: 20 | data = json.load(js) 21 | SIMPLE_SERIALIZATION = data["SIMPLE"] 22 | DIAMOND_SERIALIZATION = data["DIAMOND"] 23 | --------------------------------------------------------------------------------