├── .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 | [](https://forgemia.inra.fr/orfeo-toolbox/pyotb/-/releases)
4 | [](https://forgemia.inra.fr/orfeo-toolbox/pyotb/-/commits/develop)
5 | [](https://forgemia.inra.fr/orfeo-toolbox/pyotb/-/commits/develop)
6 | [](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 | 
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 | 
30 |
31 | Here is the final result :
32 | 
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 | OTB |
8 | pyotb |
9 |
10 |
11 |
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 | |
43 |
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 | |
62 |
63 |
64 |
65 | ### In-memory connections
66 |
67 |
68 |
69 | OTB |
70 | pyotb |
71 |
72 |
73 |
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 | |
130 |
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 | |
163 |
164 |
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 | OTB |
176 | pyotb |
177 |
178 |
179 |
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 | |
209 |
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 | |
229 |
230 |
231 |
232 | ### Slicing
233 |
234 |
235 |
236 | OTB |
237 | pyotb |
238 |
239 |
240 |
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 | |
288 |
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 | |
301 |
302 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------