├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── build-and-deploy-test.yml │ ├── build-and-deploy.yml │ ├── changelog.yml │ ├── labeled-pr.yml │ ├── release-checklist-comment.yml │ ├── release.yml │ ├── static-analysis.yml │ ├── tag-version.yml │ └── test-and-build.yml ├── .gitignore ├── .trufflehog.txt ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── ale ├── ale.py └── environment.yml ├── environment.yml ├── pyproject.toml ├── src └── multirtc │ ├── __init__.py │ ├── base.py │ ├── create_rtc.py │ ├── define_geogrid.py │ ├── dem.py │ ├── multirtc.py │ ├── rtc_options.py │ ├── sentinel1.py │ └── sicd.py └── tests ├── conftest.py └── test_dem.py /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be requested for review when someone opens a pull request. 2 | * @forrestfwilliams 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### The bug 11 | 13 | 14 | ### To Reproduce 15 | 20 | 21 | ### Additional context 22 | 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | ### Background 10 | 11 | 12 | ### Describe the solution you'd like 13 | 14 | 15 | ### Alternatives 16 | 17 | 18 | ### Additional context 19 | 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | labels: 8 | - "bumpless" 9 | -------------------------------------------------------------------------------- /.github/workflows/build-and-deploy-test.yml: -------------------------------------------------------------------------------- 1 | name: Build and upload to test PyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build-n-publish: 8 | name: Build and publish Python distributions TestPyPI 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | - name: Checkout lastest tagged version 15 | run: git checkout $(git describe --tags $(git rev-list --tags --max-count=1)) 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.x" 20 | - name: Install pypa/build 21 | run: >- 22 | python3 -m 23 | pip install 24 | build 25 | --user 26 | - name: Build a binary wheel and a source tarball 27 | run: >- 28 | python3 -m 29 | build 30 | --sdist 31 | --wheel 32 | --outdir dist/ 33 | . 34 | - name: Publish distribution to Test PyPI 35 | uses: pypa/gh-action-pypi-publish@release/v1 36 | with: 37 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 38 | repository-url: https://test.pypi.org/legacy/ 39 | -------------------------------------------------------------------------------- /.github/workflows/build-and-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and upload to PyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build-n-publish: 8 | name: Build and publish Python distributions to PyPI 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | - name: Checkout lastest tagged version 15 | run: git checkout $(git describe --tags $(git rev-list --tags --max-count=1)) 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.x" 20 | - name: Install pypa/build 21 | run: >- 22 | python3 -m 23 | pip install 24 | build 25 | --user 26 | - name: Build a binary wheel and a source tarball 27 | run: >- 28 | python3 -m 29 | build 30 | --sdist 31 | --wheel 32 | --outdir dist/ 33 | . 34 | - name: Publish distribution to PyPI 35 | uses: pypa/gh-action-pypi-publish@release/v1 36 | with: 37 | password: ${{ secrets.PYPI_API_TOKEN }} 38 | -------------------------------------------------------------------------------- /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- 1 | name: Changelog updated? 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - labeled 8 | - unlabeled 9 | - synchronize 10 | branches: 11 | - main 12 | - develop 13 | 14 | jobs: 15 | call-changelog-check-workflow: 16 | # Docs: https://github.com/ASFHyP3/actions 17 | uses: ASFHyP3/actions/.github/workflows/reusable-changelog-check.yml@v0.20.0 18 | -------------------------------------------------------------------------------- /.github/workflows/labeled-pr.yml: -------------------------------------------------------------------------------- 1 | name: Is PR labeled? 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - labeled 8 | - unlabeled 9 | - synchronize 10 | branches: 11 | - main 12 | 13 | jobs: 14 | call-labeled-pr-check-workflow: 15 | # Docs: https://github.com/ASFHyP3/actions 16 | uses: ASFHyP3/actions/.github/workflows/reusable-labeled-pr-check.yml@v0.20.0 17 | -------------------------------------------------------------------------------- /.github/workflows/release-checklist-comment.yml: -------------------------------------------------------------------------------- 1 | name: Create Release Comment 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | branches: 8 | - main 9 | 10 | jobs: 11 | call-release-workflow: 12 | # Docs: https://github.com/ASFHyP3/actions 13 | uses: ASFHyP3/actions/.github/workflows/reusable-release-checklist-comment.yml@v0.20.0 14 | permissions: 15 | pull-requests: write 16 | secrets: 17 | USER_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | call-release-workflow: 10 | # Docs: https://github.com/ASFHyP3/actions 11 | uses: ASFHyP3/actions/.github/workflows/reusable-release.yml@v0.20.0 12 | with: 13 | release_prefix: MultiRTC 14 | release_branch: main 15 | develop_branch: develop 16 | sync_pr_label: actions-bot 17 | secrets: 18 | USER_TOKEN: ${{ secrets.FORREST_BOT_PAK }} 19 | -------------------------------------------------------------------------------- /.github/workflows/static-analysis.yml: -------------------------------------------------------------------------------- 1 | name: Static analysis 2 | 3 | on: push 4 | 5 | jobs: 6 | call-secrets-analysis-workflow: 7 | # Docs: https://github.com/ASFHyP3/actions 8 | uses: ASFHyP3/actions/.github/workflows/reusable-secrets-analysis.yml@v0.20.0 9 | 10 | call-ruff-workflow: 11 | # Docs: https://github.com/ASFHyP3/actions 12 | uses: ASFHyP3/actions/.github/workflows/reusable-ruff.yml@v0.20.0 13 | -------------------------------------------------------------------------------- /.github/workflows/tag-version.yml: -------------------------------------------------------------------------------- 1 | name: Tag New Version 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | call-bump-version-workflow: 10 | # Docs: https://github.com/ASFHyP3/actions 11 | uses: ASFHyP3/actions/.github/workflows/reusable-bump-version.yml@v0.20.0 12 | with: 13 | user: forrest-bot 14 | email: ffwilliams2@alaska.edu 15 | secrets: 16 | USER_TOKEN: ${{ secrets.FORREST_BOT_PAK }} 17 | -------------------------------------------------------------------------------- /.github/workflows/test-and-build.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - develop 8 | pull_request: 9 | branches: 10 | - main 11 | - develop 12 | 13 | jobs: 14 | call-pytest-workflow: 15 | # Docs: https://github.com/ASFHyP3/actions 16 | uses: ASFHyP3/actions/.github/workflows/reusable-pytest.yml@v0.20.0 17 | with: 18 | local_package_name: mulitrtc 19 | python_versions: >- 20 | ["3.9", "3.10", "3.11", "3.12"] 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #### joe made this: http://goel.io/joe 2 | 3 | #### python #### 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | 138 | # pytype static type analyzer 139 | .pytype/ 140 | 141 | # Cython debug symbols 142 | cython_debug/ 143 | 144 | # static files generated from Django application using `collectstatic` 145 | media 146 | static 147 | 148 | 149 | #### jetbrains #### 150 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 151 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 152 | 153 | # User-specific stuff 154 | .idea/**/workspace.xml 155 | .idea/**/tasks.xml 156 | .idea/**/usage.statistics.xml 157 | .idea/**/dictionaries 158 | .idea/**/shelf 159 | 160 | # Generated files 161 | .idea/**/contentModel.xml 162 | 163 | # Sensitive or high-churn files 164 | .idea/**/dataSources/ 165 | .idea/**/dataSources.ids 166 | .idea/**/dataSources.local.xml 167 | .idea/**/sqlDataSources.xml 168 | .idea/**/dynamic.xml 169 | .idea/**/uiDesigner.xml 170 | .idea/**/dbnavigator.xml 171 | 172 | # Gradle 173 | .idea/**/gradle.xml 174 | .idea/**/libraries 175 | 176 | # Gradle and Maven with auto-import 177 | # When using Gradle or Maven with auto-import, you should exclude module files, 178 | # since they will be recreated, and may cause churn. Uncomment if using 179 | # auto-import. 180 | # .idea/artifacts 181 | # .idea/compiler.xml 182 | # .idea/jarRepositories.xml 183 | # .idea/modules.xml 184 | # .idea/*.iml 185 | # .idea/modules 186 | # *.iml 187 | # *.ipr 188 | 189 | # CMake 190 | cmake-build-*/ 191 | 192 | # Mongo Explorer plugin 193 | .idea/**/mongoSettings.xml 194 | 195 | # File-based project format 196 | *.iws 197 | 198 | # IntelliJ 199 | out/ 200 | 201 | # mpeltonen/sbt-idea plugin 202 | .idea_modules/ 203 | 204 | # JIRA plugin 205 | atlassian-ide-plugin.xml 206 | 207 | # Cursive Clojure plugin 208 | .idea/replstate.xml 209 | 210 | # Crashlytics plugin (for Android Studio and IntelliJ) 211 | com_crashlytics_export_strings.xml 212 | crashlytics.properties 213 | crashlytics-build.properties 214 | fabric.properties 215 | 216 | # Editor-based Rest Client 217 | .idea/httpRequests 218 | 219 | # Android studio 3.1+ serialized cache file 220 | .idea/caches/build_file_checksums.ser 221 | 222 | 223 | #### vim #### 224 | # Swap 225 | [._]*.s[a-v][a-z] 226 | !*.svg # comment out if you don't need vector files 227 | [._]*.sw[a-p] 228 | [._]s[a-rt-v][a-z] 229 | [._]ss[a-gi-z] 230 | [._]sw[a-p] 231 | 232 | # Session 233 | Session.vim 234 | Sessionx.vim 235 | 236 | # Temporary 237 | .netrwhist 238 | *~ 239 | # Auto-generated tag files 240 | tags 241 | # Persistent undo 242 | [._]*.un~ 243 | 244 | # Data 245 | ale/umbra* 246 | -------------------------------------------------------------------------------- /.trufflehog.txt: -------------------------------------------------------------------------------- 1 | .*gitleaks.toml$ 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [PEP 440](https://www.python.org/dev/peps/pep-0440/) 7 | and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 8 | 9 | ## [0.3.2] 10 | 11 | ### Added 12 | * ICEYE support 13 | 14 | ### Changed 15 | * Refactored library to reduce code duplication and simplify structure 16 | 17 | ## [0.3.1] 18 | 19 | ### Changed 20 | * Loading of SICD data during beta0/sigma0 creation to a chunked strategy to reduce memory requirements 21 | 22 | ### Fixed 23 | * Geolocation issue for prototype Umbra workflow related to switching to local UTM zone during processing 24 | 25 | ## [0.3.0] 26 | 27 | ### Changed 28 | * Changed PFA workflow to use a sublclass of the SicdSlc class 29 | 30 | ### Added 31 | * Cal/Val scripts for performing absolute geolocation error (ALE) assessment 32 | 33 | ### Fixed 34 | * Property assignment issues that caused bugs in the SicdRzdSlc class 35 | 36 | ## [0.2.0] 37 | 38 | ### Added 39 | * Support for Capella SICD SLC products 40 | 41 | ## [0.1.1] 42 | 43 | ### Added 44 | * Conversion to sigma0 radiometry for Umbra workflow 45 | 46 | ## [0.1.0] 47 | 48 | ### Added 49 | * Initial version 50 | 51 | ## [0.0.0] 52 | 53 | ### Added 54 | * Marking 0th release for CI/CD 55 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone. 8 | 9 | We pledge to act and interact in ways that contribute to an open, welcoming, and healthy community. 10 | 11 | ## Our Standards 12 | 13 | Examples of behavior that contributes to a positive environment for our 14 | community include: 15 | 16 | * Demonstrating empathy and kindness toward other people 17 | * Being respectful of differing opinions, viewpoints, and experiences 18 | * Giving and gracefully accepting constructive feedback 19 | * Accepting responsibility and apologizing to those affected by our mistakes, 20 | and learning from the experience 21 | * Focusing on what is best not just for us as individuals, but for the 22 | overall community 23 | 24 | Examples of unacceptable behavior include: 25 | 26 | * The use of sexualized language or imagery, and sexual attention or 27 | advances of any kind 28 | * Trolling, insulting or derogatory comments, and personal or political attacks 29 | * Public or private harassment 30 | * Publishing others' private information, such as a physical or email 31 | address, without their explicit permission 32 | * Other conduct which could reasonably be considered inappropriate in a 33 | professional setting 34 | 35 | ## Enforcement Responsibilities 36 | 37 | Community leaders are responsible for clarifying and enforcing our standards of 38 | acceptable behavior and will take appropriate and fair corrective action in 39 | response to any behavior that they deem inappropriate, threatening, offensive, 40 | or harmful. 41 | 42 | Community leaders have the right and responsibility to remove, edit, or reject 43 | comments, commits, code, wiki edits, issues, and other contributions that are 44 | not aligned to this Code of Conduct, and will communicate reasons for moderation 45 | decisions when appropriate. 46 | 47 | ## Scope 48 | 49 | This Code of Conduct applies within all community spaces, and also applies when 50 | an individual is officially representing the community in public spaces. 51 | Examples of representing our community include using an official e-mail address, 52 | posting via an official social media account, or acting as an appointed 53 | representative at an online or offline event. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported to the community leaders responsible for enforcement by emailing the 59 | development team at [UAF-asf-apd@alaska.edu](mailto:UAF-asf-apd@alaska.edu). 60 | All complaints will be reviewed and investigated promptly and fairly. 61 | 62 | All community leaders are obligated to respect the privacy and security of the 63 | reporter of any incident. 64 | 65 | ## Enforcement Guidelines 66 | 67 | Community leaders will follow these Community Impact Guidelines in determining 68 | the consequences for any action they deem in violation of this Code of Conduct: 69 | 70 | ### 1. Correction 71 | 72 | **Community Impact**: Use of inappropriate language or other behavior deemed 73 | unprofessional or unwelcome in the community. 74 | 75 | **Consequence**: A private, written warning from community leaders, providing 76 | clarity around the nature of the violation and an explanation of why the 77 | behavior was inappropriate. A public apology may be requested. 78 | 79 | ### 2. Warning 80 | 81 | **Community Impact**: A violation through a single incident or series 82 | of actions. 83 | 84 | **Consequence**: A warning with consequences for continued behavior. No 85 | interaction with the people involved, including unsolicited interaction with 86 | those enforcing the Code of Conduct, for a specified period of time. This 87 | includes avoiding interactions in community spaces as well as external channels 88 | like social media. Violating these terms may lead to a temporary or 89 | permanent ban. 90 | 91 | ### 3. Temporary Ban 92 | 93 | **Community Impact**: A serious violation of community standards, including 94 | sustained inappropriate behavior. 95 | 96 | **Consequence**: A temporary ban from any sort of interaction or public 97 | communication with the community for a specified period of time. No public or 98 | private interaction with the people involved, including unsolicited interaction 99 | with those enforcing the Code of Conduct, is allowed during this period. 100 | Violating these terms may lead to a permanent ban. 101 | 102 | ### 4. Permanent Ban 103 | 104 | **Community Impact**: Demonstrating a pattern of violation of community 105 | standards, including sustained inappropriate behavior, harassment of an 106 | individual, or aggression toward or disparagement of classes of individuals. 107 | 108 | **Consequence**: A permanent ban from any sort of public interaction within 109 | the community. 110 | 111 | ## Attribution 112 | 113 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 114 | version 2.0, available at 115 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 116 | 117 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 118 | enforcement ladder](https://github.com/mozilla/diversity). 119 | 120 | [homepage]: https://www.contributor-covenant.org 121 | 122 | For answers to common questions about this code of conduct, see the FAQ at 123 | https://www.contributor-covenant.org/faq. Translations are available at 124 | https://www.contributor-covenant.org/translations. 125 | 126 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Alaska Satellite Facility 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MultiRTC 2 | 3 | A python library for creating ISCE3-based RTCs for multiple SAR data sources 4 | 5 | > [!WARNING] 6 | > This package is still in early development. Users are encouraged to not use this package in production or other critical contexts until the v1.0.0 release. 7 | 8 | > [!IMPORTANT] 9 | > All credit for this library's RTC algorithm goes to Gustavo Shiroma and the JPL [OPERA](https://www.jpl.nasa.gov/go/opera/about-opera/) and [ISCE3](https://github.com/isce-framework/isce3) teams. This package merely allows others to use their algorithm with a wider set of SAR data sources. The RTC algorithm utilized by this package is described in [Shiroma et al., 2023](https://doi.org/10.1109/TGRS.2022.3147472). 10 | 11 | ## Usage 12 | MultiRTC allows users to create RTC products from SLC data for multiple SAR sensor platforms. Currently this list includes: 13 | 14 | Full RTC: 15 | - [Sentinel-1 Burst SLCs](https://www.earthdata.nasa.gov/data/catalog/alaska-satellite-facility-distributed-active-archive-center-sentinel-1-bursts-version) 16 | - [Capella SICD SLCs](https://www.capellaspace.com/earth-observation/data) 17 | - [ICEYE SICD SLCs](https://sar.iceye.com/5.0/productFormats/slc/) 18 | 19 | Geocode Only: 20 | - [UMBRA SICD SLCs](https://help.umbra.space/product-guide/umbra-products/umbra-product-specifications) 21 | 22 | To create an RTC, use the `multirtc` CLI entrypoint using the following pattern: 23 | 24 | ```bash 25 | multirtc PLATFORM SLC-GRANULE --resolution RESOLUTION --work-dir WORK-DIR 26 | ``` 27 | Where `PLATFORM` is the name of the satellite platform (currently `S1`, `CAPELLA`, `ICEYE` or `UMBRA`), `SLC-GRANULE` is the name of the SLC granule, `RESOLUTION` is the desired output resolution of the RTC image in meters, and `WORK-DIR` is the name of the working directory to perform processing in. Inputs such as the SLC data, DEM, and external orbit information are stored in `WORK-DIR/input`, while the RTC image and associated outputs are stored in `WORK-DIR/output` once processing is complete. SLC data that is available in the [Alaska Satellite Facility's data archive](https://search.asf.alaska.edu/#/?maxResults=250) (such as Sentinel-1 Burst SLCs) will be automatically downloaded to the input directory, but data not available in this archive (commercial datasets) are required to be staged in the input directory prior to processing. 28 | 29 | Output RTC pixel values represent gamma0 power. 30 | 31 | ### Current Umbra Implementation 32 | Currently, the Umbra processor only supports basic geocoding and not full RTC processing. ISCE3's RTC algorithm is only designed to work with Range Migration Algorithm (RMA) focused SLC products, but Umbra creates their data using the Polar Format Algorithm (PFA). Using an [approach detailed by Piyush Agram](https://arxiv.org/abs/2503.07889v1) to adapt RMA approaches to the PFA image geometry, we have developed a workflow to geocode an Umbra SLC but there is more work to be done to implement full RTC processing. Since full RTC is not yet implemented, Umbra geocoded pixel values represent sigma0 power. 33 | 34 | ### DEM options 35 | Currently, only the OPERA DEM is supported. This is a global Height Above Ellipsoid DEM sourced from the [COP-30 DEM](https://portal.opentopography.org/raster?opentopoID=OTSDEM.032021.4326.3). In the future, we hope to support a wider variety of automatically retrieved and user provided DEMs. 36 | 37 | ## When will support for [insert SAR provider here] products be added? 38 | We're currently working on this package on a "best effort" basis with no specific timeline for any particular dataset. We would love to add support for every SAR dataset ASAP, but we only have so much time to devote to this package. If you want a particular dataset to be prioritized there are several things you can do: 39 | 40 | - [Open an issue](https://github.com/forrestfwilliams/multirtc/issues/new) requesting support for your dataset and encourage others to like or comment on it. 41 | - Provides links to example datasets over the Rosamond, California corner reflector site (Lat/Lon 34.799,-118.095) for performing cal/val. 42 | - Reach out to us about funding the development required to add your dataset. 43 | 44 | ## Developer Setup 45 | 1. Ensure that conda is installed on your system (we recommend using [mambaforge](https://github.com/conda-forge/miniforge#mambaforge) to reduce setup times). 46 | 2. Download a local version of the `multirtc` repository (`git clone https://github.com/forrestfwilliams/multirtc.git`) 47 | 3. In the base directory for this project call `mamba env create -f environment.yml` to create your Python environment, then activate it (`mamba activate multirtc`) 48 | 4. Finally, install a development version of the package (`python -m pip install -e .`) 49 | 50 | To run all commands in sequence use: 51 | ```bash 52 | git clone https://github.com/forrestfwilliams/multirtc.git 53 | cd multirtc 54 | mamba env create -f environment.yml 55 | mamba activate multirtc 56 | python -m pip install -e . 57 | ``` 58 | 59 | ## License 60 | MultiRTC is licensed under the BSD-3-Clause license. See the LICENSE file for more details. 61 | 62 | ## Code of conduct 63 | We strive to create a welcoming and inclusive community for all contributors to this project. As such, all contributors to this project are expected to adhere to our code of conduct. 64 | 65 | Please see `CODE_OF_CONDUCT.md` for the full code of conduct text. 66 | 67 | ## Contributing 68 | Contributions to this project plugin are welcome! If you would like to contribute, please submit a pull request on the GitHub repository. 69 | 70 | ## Contact Us 71 | Want to talk about this project? We would love to hear from you! 72 | 73 | Found a bug? Want to request a feature? 74 | [open an issue](https://github.com/forrestfwilliams/multirtc/issues/new) 75 | -------------------------------------------------------------------------------- /ale/ale.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | from datetime import datetime 3 | from pathlib import Path 4 | 5 | import isce3 6 | import matplotlib.pyplot as plt 7 | import numpy as np 8 | import pandas as pd 9 | import requests 10 | from lmfit import Model 11 | from osgeo import gdal, osr 12 | from pyproj import Transformer 13 | from shapely import box 14 | from shapely.geometry import Point 15 | from shapely.ops import transform 16 | 17 | 18 | gdal.UseExceptions() 19 | 20 | 21 | def gaussfit(x, y, A, x0, y0, sigma_x, sigma_y, theta): 22 | theta = np.radians(theta) 23 | sigx2 = sigma_x**2 24 | sigy2 = sigma_y**2 25 | a = np.cos(theta) ** 2 / (2 * sigx2) + np.sin(theta) ** 2 / (2 * sigy2) 26 | b = np.sin(theta) ** 2 / (2 * sigx2) + np.cos(theta) ** 2 / (2 * sigy2) 27 | c = np.sin(2 * theta) / (4 * sigx2) - np.sin(2 * theta) / (4 * sigy2) 28 | 29 | expo = -a * (x - x0) ** 2 - b * (y - y0) ** 2 - 2 * c * (x - x0) * (y - y0) 30 | return A * np.exp(expo) 31 | 32 | 33 | def get_cr_df(bounds, epsg, date, outdir): 34 | rosamond_bounds = [-124.409591, 32.534156, -114.131211, 42.009518] 35 | rosamond_bounds = box(*rosamond_bounds) 36 | transformer = Transformer.from_crs('EPSG:4326', f'EPSG:{epsg}', always_xy=True) 37 | rosamond_bounds = transform(transformer.transform, rosamond_bounds) 38 | assert bounds.intersects(rosamond_bounds), f'Images does not intersect with Rosamond bounds {rosamond_bounds}.' 39 | date_str = date.strftime('%Y-%m-%d+%H\u0021%M') 40 | 41 | crdata = outdir / f'{date_str.split("+")[0]}_crdata.csv' 42 | if not crdata.exists(): 43 | res = requests.get( 44 | f'https://uavsar.jpl.nasa.gov/cgi-bin/corner-reflectors.pl?date={str(date_str)}&project=rosamond_plate_location' 45 | ) 46 | crdata.write_bytes(res.content) 47 | cr_df = pd.read_csv(crdata) 48 | new_cols = { 49 | ' "Corner ID"': 'ID', 50 | 'Latitude (deg)': 'lat', 51 | 'Longitude (deg)': 'lon', 52 | 'Azimuth (deg)': 'azm', 53 | 'Height Above Ellipsoid (m)': 'hgt', 54 | 'Side Length (m)': 'slen', 55 | } 56 | cr_df.rename(columns=new_cols, inplace=True) 57 | cr_df.drop(columns=cr_df.keys()[-1], inplace=True) 58 | return cr_df 59 | 60 | 61 | def add_image_location(cr_df, epsg, x_start, y_start, x_spacing, y_spacing, bounds): 62 | blank = [np.nan] * cr_df.shape[0] 63 | blank_bool = [False] * cr_df.shape[0] 64 | cr_df = cr_df.assign( 65 | UTMx=blank, 66 | UTMy=blank, 67 | xloc=blank, 68 | yloc=blank, 69 | xloc_floats=blank, 70 | yloc_floats=blank, 71 | inPoly=blank_bool, 72 | ) 73 | transformer = Transformer.from_crs('EPSG:4326', f'EPSG:{epsg}', always_xy=True) 74 | for idx, row in cr_df.iterrows(): 75 | row['UTMx'], row['UTMy'] = transformer.transform(row['lon'], row['lat']) 76 | row['xloc_floats'] = (row['UTMx'] - x_start) / x_spacing 77 | row['xloc'] = int(round(row['xloc_floats'])) 78 | row['yloc_floats'] = (row['UTMy'] - y_start) / y_spacing 79 | row['yloc'] = int(round(row['yloc_floats'])) 80 | row['inPoly'] = bounds.intersects(Point(row['UTMx'], row['UTMy'])) 81 | cr_df.iloc[idx] = row 82 | 83 | cr_df = cr_df[cr_df['inPoly']] 84 | cr_df.drop('inPoly', axis=1, inplace=True) 85 | cr_df = cr_df.reset_index(drop=True) 86 | return cr_df 87 | 88 | 89 | def filter_valid_data(cr_df, data): 90 | cr_df = cr_df.assign(has_data=False) 91 | for idx, row in cr_df.iterrows(): 92 | xloc = int(row['xloc']) 93 | yloc = int(row['yloc']) 94 | local_data = data[yloc - 2 : yloc + 2, xloc - 3 : xloc + 3] 95 | row['has_data'] = bool(~np.all(np.isnan(local_data))) 96 | cr_df.iloc[idx] = row 97 | cr_df = cr_df[cr_df['has_data']] 98 | cr_df.drop('has_data', axis=1, inplace=True) 99 | cr_df = cr_df.reset_index(drop=True) 100 | cr_df = cr_df.loc[cr_df['slen'] > 0.8].reset_index(drop=True) # excluding SWOT CRs (0.7 m as a side length) 101 | return cr_df 102 | 103 | 104 | def filter_orientation(cr_df, azimuth_angle): 105 | looking_east = azimuth_angle < 180 106 | if looking_east: 107 | cr_df = cr_df[(cr_df['azm'] < 200) & (cr_df['azm'] > 20)].reset_index(drop=True) 108 | else: 109 | cr_df = cr_df[cr_df['azm'] > 340].reset_index(drop=True) 110 | return cr_df 111 | 112 | 113 | def plot_crs_on_image(cr_df, data, outdir, fileprefix): 114 | buffer = 50 115 | min_x = cr_df['xloc'].min() - buffer 116 | max_x = cr_df['xloc'].max() + buffer 117 | min_y = cr_df['yloc'].min() - buffer 118 | max_y = cr_df['yloc'].max() + buffer 119 | 120 | fig, ax = plt.subplots(figsize=(15, 7)) 121 | ax.imshow(data, cmap='gray', interpolation='bilinear', vmin=0.3, vmax=1.7, origin='upper') 122 | ax.set_xlim(min_x, max_x) 123 | ax.set_ylim(min_y, max_y) 124 | ax.axis('off') 125 | 126 | for sl in pd.unique(cr_df.slen): 127 | xx = cr_df.loc[cr_df['slen'] == sl]['xloc'] 128 | yy = cr_df.loc[cr_df['slen'] == sl]['yloc'] 129 | id = cr_df.loc[cr_df['slen'] == sl]['ID'] 130 | color = {2.4384: 'blue', 4.8: 'red', 2.8: 'yellow'}.get(sl, 'green') 131 | ax.scatter(xx, yy, color=color, marker='o', facecolor='none', lw=1) 132 | for id_ann, xx_ann, yy_ann in zip(id, xx, yy): 133 | id_ann = f'{int(id_ann)} ({sl:.1f} m)' 134 | ax.annotate(id_ann, (xx_ann, yy_ann), fontsize=10, color='grey') 135 | 136 | ax.set_aspect(1) 137 | ax.set_title('Corner Reflector Locations') 138 | plt.gca().invert_yaxis() 139 | fig.savefig(outdir / f'{fileprefix}_CR_locations.png', dpi=300, bbox_inches='tight') 140 | 141 | 142 | def calculate_ale_for_cr(point, data, outdir, fileprefix, search_window=100, oversample_factor=4): 143 | ybuff = search_window // 2 144 | xbuff = search_window // 2 145 | yrange = np.s_[int(point['yloc'] - ybuff) : int(point['yloc'] + ybuff)] 146 | xrange = np.s_[int(point['xloc'] - xbuff) : int(point['xloc'] + xbuff)] 147 | cropped_data = data[yrange, xrange] 148 | yind, xind = np.unravel_index(np.argmax(cropped_data, axis=None), cropped_data.shape) 149 | 150 | center_buff = 32 151 | yind_full = int(point['yloc'] - ybuff) + yind 152 | xind_full = int(point['xloc'] - xbuff) + xind 153 | ycenter = np.s_[int(yind_full - center_buff) : int(yind_full + center_buff)] 154 | xcenter = np.s_[int(xind_full - center_buff) : int(xind_full + center_buff)] 155 | centered_data = data[ycenter, xcenter] 156 | 157 | data_ovs = isce3.cal.point_target_info.oversample(centered_data, oversample_factor, baseband=True) 158 | 159 | yoff2 = int(data_ovs.shape[0] / 2) 160 | xoff2 = int(data_ovs.shape[1] / 2) 161 | numpix = 8 162 | zoom_half_size = numpix * oversample_factor 163 | data_ovs_zoom = data_ovs[ 164 | yoff2 - zoom_half_size : yoff2 + zoom_half_size, xoff2 - zoom_half_size : xoff2 + zoom_half_size 165 | ] 166 | 167 | N = numpix * 2 * oversample_factor 168 | x = np.linspace(0, numpix * 2 * oversample_factor - 1, N) 169 | y = np.linspace(0, numpix * 2 * oversample_factor - 1, N) 170 | Xg, Yg = np.meshgrid(x, y) 171 | fmodel = Model(gaussfit, independent_vars=('x', 'y')) 172 | theta = 0.1 # deg 173 | x0 = numpix * oversample_factor 174 | y0 = numpix * oversample_factor 175 | sigx = 2 176 | sigy = 5 177 | A = np.max(data_ovs_zoom) 178 | result = fmodel.fit(data_ovs_zoom, x=Xg, y=Yg, A=A, x0=x0, y0=y0, sigma_x=sigx, sigma_y=sigy, theta=theta) 179 | fit = fmodel.func(Xg, Yg, **result.best_values) 180 | 181 | ypeak_ovs = result.best_values['y0'] + yoff2 - zoom_half_size 182 | ypeak_centered = ypeak_ovs / oversample_factor 183 | ypeak = ypeak_centered + yind_full - center_buff 184 | point['yloc_cr'] = ypeak 185 | 186 | xpeak_ovs = result.best_values['x0'] + xoff2 - zoom_half_size 187 | xpeak_centered = xpeak_ovs / oversample_factor 188 | xpeak = xpeak_centered + xind_full - center_buff 189 | point['xloc_cr'] = xpeak 190 | 191 | xreal_centered = point['xloc'] - xind_full + center_buff 192 | xreal_ovs = xreal_centered * oversample_factor 193 | 194 | yreal_centered = point['yloc'] - yind_full + center_buff 195 | yreal_ovs = yreal_centered * oversample_factor 196 | 197 | plt.rcParams.update({'font.size': 14}) 198 | fig, ax = plt.subplots(1, 3, figsize=(15, 7)) 199 | ax[0].imshow(centered_data, cmap='gray', interpolation=None, origin='upper') 200 | ax[0].plot(xpeak_centered, ypeak_centered, 'r+', label='Return Peak') 201 | ax[0].plot(xreal_centered, yreal_centered, 'b+', label='CR Location') 202 | ax[0].legend() 203 | ax[0].set_title(f'Corner Reflector ID: {int(point["ID"])}') 204 | ax[1].imshow(data_ovs, cmap='gray', interpolation=None, origin='upper') 205 | ax[1].plot(xpeak_ovs, ypeak_ovs, 'r+') 206 | ax[1].plot(xreal_ovs, yreal_ovs, 'b+') 207 | ax[1].set_title(f'Oversampled Corner Reflector ID: {point["ID"]}') 208 | ax[2].imshow(fit, cmap='gray', interpolation=None, origin='upper') 209 | ax[2].plot(result.best_values['x0'], result.best_values['y0'], 'r+') 210 | ax[2].set_title(f'Gaussian Fit Corner Reflector ID: {int(point["ID"])}') 211 | [axi.axis('off') for axi in ax] 212 | fig.tight_layout() 213 | fig.savefig(outdir / f'{fileprefix}_CR_{int(point["ID"])}.png', dpi=300, bbox_inches='tight') 214 | 215 | return point 216 | 217 | 218 | def cr_mean(data): 219 | return np.round(np.nanmean(data), 3) 220 | 221 | 222 | def cr_spread(data): 223 | return np.round(np.nanstd(data) / np.sqrt(np.size(data)), 3) 224 | 225 | 226 | def plot_ale(cr_df, azmangle, outdir, fileprefix): 227 | east_ale = cr_df['easting_ale'] 228 | north_ale = cr_df['northing_ale'] 229 | ale = cr_df['ale'] 230 | los = np.deg2rad(90 - azmangle) 231 | fig, ax = plt.subplots(figsize=(8, 8)) 232 | ax.scatter(east_ale, north_ale, s=20, c='k', alpha=0.6, marker='o') 233 | ax.annotate( 234 | 'LOS', 235 | xytext=(np.cos(los) * 10, np.sin(los) * 10), 236 | xy=(0, 0), 237 | arrowprops=dict(edgecolor='darkblue', arrowstyle='<-'), 238 | color='darkblue', 239 | ) 240 | ax.grid(True) 241 | ax.set_xlim(-15.25, 15.25) 242 | ax.set_ylim(-15.25, 15.25) 243 | ax.axhline(0, color='black') 244 | ax.axvline(0, color='black') 245 | east_metric = f'Easting: {cr_mean(east_ale)} +/- {cr_spread(east_ale)} m' 246 | north_metric = f'Northing: {cr_mean(north_ale)} +/- {cr_spread(north_ale)} m' 247 | overall_metric = f'Overall: {cr_mean(ale)} +/- {cr_spread(ale)} m' 248 | ax.set_title(f'{east_metric}, {north_metric}, {overall_metric}', fontsize=10) 249 | ax.set_xlabel('Easting Error (m)') 250 | ax.set_ylabel('Northing Error (m)') 251 | fig.suptitle('Absolute Location Error') 252 | plt.savefig(outdir / f'{fileprefix}_ale.png', dpi=300, bbox_inches='tight', transparent=True) 253 | 254 | 255 | def ale(filepath, date, azmangle, outdir, fileprefix): 256 | outdir.mkdir(parents=True, exist_ok=True) 257 | ds = gdal.Open(str(filepath)) 258 | data = ds.GetRasterBand(1).ReadAsArray() 259 | geotransform = ds.GetGeoTransform() 260 | x_start = geotransform[0] + 0.5 * geotransform[1] 261 | y_start = geotransform[3] + 0.5 * geotransform[5] 262 | x_end = x_start + geotransform[1] * ds.RasterXSize 263 | y_end = y_start + geotransform[5] * ds.RasterYSize 264 | bounds = (x_start, y_start, x_end, y_end) 265 | bounds = box(*bounds) 266 | x_spacing = geotransform[1] 267 | y_spacing = geotransform[5] 268 | 269 | srs = osr.SpatialReference() 270 | srs.ImportFromWkt(ds.GetProjectionRef()) 271 | epsg = int(srs.GetAuthorityCode(None)) 272 | cr_df = get_cr_df(bounds, epsg, date, outdir) 273 | cr_df = add_image_location(cr_df, epsg, x_start, y_start, x_spacing, y_spacing, bounds) 274 | cr_df = filter_valid_data(cr_df, data) 275 | cr_df = filter_orientation(cr_df, azmangle) 276 | cr_df = cr_df.assign(yloc_cr=np.nan, xloc_cr=np.nan) 277 | plot_crs_on_image(cr_df, data, outdir, fileprefix) 278 | for idx, cr in cr_df.iterrows(): 279 | cr = calculate_ale_for_cr(cr, data, outdir, fileprefix) 280 | cr_df.iloc[idx] = cr 281 | 282 | cr_df['easting_ale'] = (cr_df['xloc_cr'] - cr_df['xloc_floats']) * x_spacing 283 | cr_df['northing_ale'] = (cr_df['yloc_cr'] - cr_df['yloc_floats']) * y_spacing 284 | cr_df['ale'] = np.sqrt(cr_df['northing_ale'] ** 2 + cr_df['easting_ale'] ** 2) 285 | cr_df.to_csv(outdir / (fileprefix + '_ale.csv'), index=False) 286 | plot_ale(cr_df, azmangle, outdir, fileprefix) 287 | 288 | 289 | def main(): 290 | """Example: 291 | python ale.py rtc.tif 2024-12-03 DESC out --fileprefix rtc 292 | """ 293 | parser = ArgumentParser(description='Absolute Location Error Estimation.') 294 | parser.add_argument('filepath', type=str, help='Path to the file to be processed') 295 | parser.add_argument('date', type=str, help='Date of the image collection (YYYY-MM-DD)') 296 | parser.add_argument('azmangle', type=int, help='Azimuth angle of the image (clockwise from North in degrees)') 297 | parser.add_argument('outdir', type=str, help='Directory to save the results') 298 | parser.add_argument( 299 | '--fileprefix', type=str, help='Prefix for the output filenames (default: input filename)', default=None 300 | ) 301 | args = parser.parse_args() 302 | args.filepath = Path(args.filepath) 303 | args.date = datetime.strptime(args.date, '%Y-%m-%d') 304 | assert 0 <= args.azmangle <= 360, f'Azimuth angle {args.azmangle} is out of range [0, 360].' 305 | args.outdir = Path(args.outdir) 306 | if args.fileprefix is None: 307 | args.fileprefix = Path(args.filepath).stem 308 | assert args.filepath.exists(), f'File {args.filepath} does not exist.' 309 | 310 | ale(args.filepath, args.date, args.azmangle, args.outdir, args.fileprefix) 311 | 312 | 313 | if __name__ == '__main__': 314 | main() 315 | -------------------------------------------------------------------------------- /ale/environment.yml: -------------------------------------------------------------------------------- 1 | name: multirtc-acc 2 | channels: 3 | - conda-forge 4 | - nodefaults 5 | dependencies: 6 | # For running 7 | - python 8 | - pip 9 | - gdal>=3.0 10 | - numpy>=1.20 11 | - isce3 12 | - requests 13 | - lxml 14 | - shapely 15 | - pyproj 16 | - pandas 17 | - matplotlib 18 | - lmfit 19 | # For static analysis 20 | - ruff 21 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: multirtc 2 | channels: 3 | - conda-forge 4 | - nodefaults 5 | dependencies: 6 | # For running 7 | - python 8 | - pip 9 | - gdal>=3.0 10 | - numpy>=1.20 11 | - isce3 12 | - boto3 13 | - requests 14 | - lxml 15 | - shapely 16 | - pyproj 17 | - s1reader>=0.2.5 18 | - sarpy 19 | - burst2safe 20 | - tqdm 21 | - hyp3lib 22 | # For packaging, and testing 23 | - setuptools 24 | - setuptools_scm 25 | - wheel 26 | - pytest 27 | - pytest-console-scripts 28 | - pytest-cov 29 | - ruff 30 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "setuptools_scm[toml]>=6.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "multirtc" 7 | requires-python = ">=3.9" 8 | authors = [ 9 | {name="Forrest Williams", email="ffwilliams2@alaska.edu"}, 10 | ] 11 | description = "Python library for multi-sensor RTC processing using the OPERA algorithm" 12 | license = {text = "BSD-3-Clause"} 13 | classifiers=[ 14 | "Intended Audience :: Science/Research", 15 | "License :: OSI Approved :: BSD License", 16 | "Natural Language :: English", 17 | "Operating System :: OS Independent", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.9", 20 | ] 21 | dependencies = [ 22 | "gdal", 23 | "numpy", 24 | # "isce3", Not on pip 25 | "boto3", 26 | "requests", 27 | "lxml", 28 | "shapely", 29 | "pyproj", 30 | "s1reader", 31 | "sarpy", 32 | "burst2safe", 33 | "tqdm", 34 | "hyp3lib", 35 | ] 36 | dynamic = ["version", "readme"] 37 | 38 | [project.optional-dependencies] 39 | develop = [ 40 | "pytest", 41 | "pytest-cov", 42 | "pytest-console-scripts", 43 | "ruff", 44 | ] 45 | 46 | [project.urls] 47 | Homepage = "https://github.com/forrestfwilliams/multirtc" 48 | Documentation = "https://github.com/forrestfwilliams/multirtc" 49 | 50 | [project.scripts] 51 | multirtc = "multirtc.multirtc:main" 52 | 53 | [tool.pytest.ini_options] 54 | testpaths = ["tests"] 55 | script_launch_mode = "subprocess" 56 | 57 | [tool.setuptools] 58 | include-package-data = true 59 | zip-safe = false 60 | 61 | [tool.setuptools.dynamic] 62 | readme = {file = ["README.md"], content-type = "text/markdown"} 63 | 64 | [tool.setuptools.packages.find] 65 | where = ["src"] 66 | 67 | [tool.setuptools_scm] 68 | 69 | [tool.ruff] 70 | line-length = 120 71 | # The directories to consider when resolving first- vs. third-party imports. 72 | # See: https://docs.astral.sh/ruff/settings/#src 73 | src = ["src", "tests"] 74 | 75 | [tool.ruff.format] 76 | indent-style = "space" 77 | quote-style = "single" 78 | 79 | [tool.ruff.lint] 80 | extend-select = [ 81 | "I", # isort: https://docs.astral.sh/ruff/rules/#isort-i 82 | "UP", # pyupgrade: https://docs.astral.sh/ruff/rules/#pyupgrade-up 83 | # TODO: Uncomment the following extensions and address their warnings: 84 | # "D", # pydocstyle: https://docs.astral.sh/ruff/rules/#pydocstyle-d 85 | # "ANN", # annotations: https://docs.astral.sh/ruff/rules/#flake8-annotations-ann 86 | # "PTH", # use-pathlib-pth: https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth 87 | ] 88 | 89 | [tool.ruff.lint.pydocstyle] 90 | convention = "google" 91 | 92 | [tool.ruff.lint.isort] 93 | case-sensitive = true 94 | lines-after-imports = 2 95 | -------------------------------------------------------------------------------- /src/multirtc/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import version 2 | 3 | 4 | __version__ = version(__name__) 5 | 6 | __all__ = [ 7 | '__version__', 8 | ] 9 | -------------------------------------------------------------------------------- /src/multirtc/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from datetime import datetime 3 | from pathlib import Path 4 | 5 | import isce3 6 | import numpy as np 7 | from shapely.geometry import Point, Polygon 8 | 9 | 10 | def to_isce_datetime(dt: datetime | np.datetime64) -> isce3.core.DateTime: 11 | if isinstance(dt, datetime): 12 | return isce3.core.DateTime(dt) 13 | elif isinstance(dt, np.datetime64): 14 | return isce3.core.DateTime(dt.item()) 15 | else: 16 | raise ValueError(f'Unsupported datetime type: {type(dt)}. Expected datetime or np.datetime64.') 17 | 18 | 19 | def from_isce_datetime(dt: isce3.core.DateTime) -> datetime: 20 | return datetime.fromisoformat(dt.isoformat()) 21 | 22 | 23 | def print_wkt(slc): 24 | radar_grid = slc.radar_grid 25 | dem = isce3.geometry.DEMInterpolator(slc.scp_hae) 26 | doppler = slc.doppler_centroid_grid 27 | wkt = isce3.geometry.get_geo_perimeter_wkt( 28 | grid=radar_grid, orbit=slc.orbit, doppler=doppler, dem=dem, points_per_edge=3 29 | ) 30 | print(wkt) 31 | 32 | 33 | class Slc(ABC): 34 | """Template class for SLC objects that defines a common interface and enforces required attributes.""" 35 | 36 | required_attributes = { 37 | 'id': str, 38 | 'filepath': Path, 39 | 'footprint': Polygon, 40 | 'center': Point, 41 | 'lookside': str, # 'right' or 'left' 42 | 'wavelength': float, 43 | 'polarization': str, 44 | 'shape': tuple, 45 | 'range_pixel_spacing': float, 46 | 'reference_time': datetime, 47 | 'sensing_start': float, 48 | 'prf': float, 49 | 'supports_rtc': bool, 50 | 'supports_bistatic_delay': bool, 51 | 'supports_static_tropo': bool, 52 | 'orbit': object, # Replace with actual orbit type 53 | 'radar_grid': object, # Replace with actual radar grid type 54 | 'doppler_centroid_grid': object, # Replace with actual doppler centroid grid type 55 | } 56 | 57 | # I prefer this setup to enforce properties over forcing subclasses to have a bunch of @property statements 58 | def __init_subclass__(cls): 59 | super().__init_subclass__() 60 | original_init = cls.__init__ 61 | 62 | def wrapped_init(self, *args, **kwargs): 63 | original_init(self, *args, **kwargs) 64 | for attr, expected_type in cls.required_attributes.items(): 65 | if not hasattr(self, attr): 66 | raise NotImplementedError(f'{cls.__name__} must define self.{attr}') 67 | if not isinstance(getattr(self, attr), expected_type): 68 | raise TypeError( 69 | f'{cls.__name__}.{attr} must be of type {expected_type.__name__},' 70 | f'got {type(getattr(self, attr)).__name__}' 71 | ) 72 | 73 | cls.__init__ = wrapped_init 74 | 75 | @abstractmethod 76 | def create_geogrid(self, spacing_meters: int) -> isce3.product.GeoGridParameters: 77 | """ 78 | Create a geogrid for the SLC object with the specified resolution. 79 | 80 | Args: 81 | spacing_meters: Pixel spacing in meters for the geogrid. 82 | 83 | Returns: 84 | The geogrid parameters for the SLC object. 85 | """ 86 | pass 87 | -------------------------------------------------------------------------------- /src/multirtc/create_rtc.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import logging 3 | import os 4 | import time 5 | 6 | import isce3 7 | import numpy as np 8 | import pyproj 9 | from osgeo import gdal 10 | from scipy import ndimage 11 | from tqdm import tqdm 12 | 13 | from multirtc.define_geogrid import get_point_epsg 14 | from multirtc.sentinel1 import S1BurstSlc 15 | from multirtc.sicd import SicdSlc 16 | 17 | 18 | logger = logging.getLogger('rtc_s1') 19 | 20 | LAYER_NAME_LAYOVER_SHADOW_MASK = 'mask' 21 | LAYER_NAME_RTC_ANF_GAMMA0_TO_SIGMA0 = 'rtc_anf_gamma0_to_sigma0' 22 | LAYER_NAME_NUMBER_OF_LOOKS = 'number_of_looks' 23 | LAYER_NAME_INCIDENCE_ANGLE = 'incidence_angle' 24 | LAYER_NAME_LOCAL_INCIDENCE_ANGLE = 'local_incidence_angle' 25 | LAYER_NAME_PROJECTION_ANGLE = 'projection_angle' 26 | LAYER_NAME_RTC_ANF_PROJECTION_ANGLE = 'rtc_anf_projection_angle' 27 | LAYER_NAME_RANGE_SLOPE = 'range_slope' 28 | LAYER_NAME_DEM = 'interpolated_dem' 29 | 30 | 31 | def compute_correction_lut( 32 | burst, 33 | dem_raster, 34 | scratch_path, 35 | rg_step_meters, 36 | az_step_meters, 37 | apply_bistatic_delay_correction, 38 | apply_static_tropospheric_delay_correction, 39 | ): 40 | """ 41 | Compute lookup table for geolocation correction. 42 | Applied corrections are: bistatic delay (azimuth), 43 | static troposphere delay (range) 44 | 45 | Parameters 46 | ---------- 47 | burst_in: Sentinel1BurstSlc 48 | Input burst SLC 49 | dem_raster: isce3.io.raster 50 | DEM to run rdr2geo 51 | scratch_path: str 52 | Scratch path where the radargrid rasters will be saved 53 | rg_step_meters: float 54 | LUT spacing in slant range. Unit: meters 55 | az_step_meters: float 56 | LUT spacing in azimth direction. Unit: meters 57 | apply_bistatic_delay_correction: bool 58 | Flag to indicate whether the bistatic delay correciton should be applied 59 | apply_static_tropospheric_delay_correction: bool 60 | Flag to indicate whether the static tropospheric delay correction should be 61 | applied 62 | 63 | Returns 64 | ------- 65 | rg_lut, az_lut: isce3.core.LUT2d 66 | LUT2d for geolocation correction in slant range and azimuth direction 67 | """ 68 | 69 | rg_lut = None 70 | az_lut = None 71 | 72 | # approximate conversion of az_step_meters from meters to seconds 73 | numrow_orbit = burst.orbit.position.shape[0] 74 | vel_mid = burst.orbit.velocity[numrow_orbit // 2, :] 75 | spd_mid = np.linalg.norm(vel_mid) 76 | pos_mid = burst.orbit.position[numrow_orbit // 2, :] 77 | alt_mid = np.linalg.norm(pos_mid) 78 | 79 | r = 6371000.0 # geometric mean of WGS84 ellipsoid 80 | 81 | az_step_sec = (az_step_meters * alt_mid) / (spd_mid * r) 82 | # Bistatic - azimuth direction 83 | bistatic_delay = burst.bistatic_delay(range_step=rg_step_meters, az_step=az_step_sec) 84 | 85 | if apply_bistatic_delay_correction: 86 | az_lut = isce3.core.LUT2d( 87 | bistatic_delay.x_start, 88 | bistatic_delay.y_start, 89 | bistatic_delay.x_spacing, 90 | bistatic_delay.y_spacing, 91 | -bistatic_delay.data, 92 | ) 93 | 94 | if not apply_static_tropospheric_delay_correction: 95 | return rg_lut, az_lut 96 | 97 | # Calculate rdr2geo rasters 98 | epsg = dem_raster.get_epsg() 99 | proj = isce3.core.make_projection(epsg) 100 | ellipsoid = proj.ellipsoid 101 | 102 | rdr_grid = burst.as_isce3_radargrid(az_step=az_step_sec, rg_step=rg_step_meters) 103 | 104 | grid_doppler = isce3.core.LUT2d() 105 | 106 | # Initialize the rdr2geo object 107 | rdr2geo_obj = isce3.geometry.Rdr2Geo(rdr_grid, burst.orbit, ellipsoid, grid_doppler, threshold=1.0e-8) 108 | 109 | # Get the rdr2geo raster needed for SET computation 110 | topo_output = { 111 | f'{scratch_path}/height.rdr': gdal.GDT_Float32, 112 | f'{scratch_path}/incidence_angle.rdr': gdal.GDT_Float32, 113 | } 114 | 115 | raster_list = [] 116 | for fname, dtype in topo_output.items(): 117 | topo_output_raster = isce3.io.Raster(fname, rdr_grid.width, rdr_grid.length, 1, dtype, 'ENVI') 118 | raster_list.append(topo_output_raster) 119 | 120 | height_raster, incidence_raster = raster_list 121 | 122 | rdr2geo_obj.topo( 123 | dem_raster, x_raster=None, y_raster=None, height_raster=height_raster, incidence_angle_raster=incidence_raster 124 | ) 125 | 126 | height_raster.close_dataset() 127 | incidence_raster.close_dataset() 128 | 129 | # Load height and incidence angle layers 130 | height_arr = gdal.Open(f'{scratch_path}/height.rdr', gdal.GA_ReadOnly).ReadAsArray() 131 | incidence_angle_arr = gdal.Open(f'{scratch_path}/incidence_angle.rdr', gdal.GA_ReadOnly).ReadAsArray() 132 | 133 | # static troposphere delay - range direction 134 | # reference: 135 | # Breit et al., 2010, TerraSAR-X SAR Processing and Products, 136 | # IEEE Transactions on Geoscience and Remote Sensing, 48(2), 727-740. 137 | # DOI: 10.1109/TGRS.2009.2035497 138 | zenith_path_delay = 2.3 139 | reference_height = 6000.0 140 | tropo = zenith_path_delay / np.cos(np.deg2rad(incidence_angle_arr)) * np.exp(-1 * height_arr / reference_height) 141 | 142 | # Prepare the computation results into LUT2d 143 | rg_lut = isce3.core.LUT2d( 144 | bistatic_delay.x_start, bistatic_delay.y_start, bistatic_delay.x_spacing, bistatic_delay.y_spacing, tropo 145 | ) 146 | 147 | return rg_lut, az_lut 148 | 149 | 150 | def compute_layover_shadow_mask( 151 | radar_grid: isce3.product.RadarGridParameters, 152 | orbit: isce3.core.Orbit, 153 | geogrid_in: isce3.product.GeoGridParameters, 154 | dem_raster: isce3.io.Raster, 155 | filename_out: str, 156 | output_raster_format: str, 157 | scratch_dir: str, 158 | shadow_dilation_size: int, 159 | threshold_rdr2geo: float = 1.0e-7, 160 | numiter_rdr2geo: int = 25, 161 | extraiter_rdr2geo: int = 10, 162 | lines_per_block_rdr2geo: int = 1000, 163 | threshold_geo2rdr: float = 1.0e-7, 164 | numiter_geo2rdr: int = 25, 165 | memory_mode: isce3.core.GeocodeMemoryMode = None, 166 | geocode_options=None, 167 | doppler=None, 168 | ): 169 | """ 170 | Compute the layover/shadow mask and geocode it 171 | 172 | Parameters 173 | ----------- 174 | radar_grid: isce3.product.RadarGridParameters 175 | Radar grid 176 | orbit: isce3.core.Orbit 177 | Orbit defining radar motion on input path 178 | geogrid_in: isce3.product.GeoGridParameters 179 | Geogrid to geocode the layover/shadow mask in radar grid 180 | geogrid_in: isce3.product.GeoGridParameters 181 | Geogrid to geocode the layover/shadow mask in radar grid 182 | dem_raster: isce3.io.Raster 183 | DEM raster 184 | filename_out: str 185 | Path to the geocoded layover/shadow mask 186 | output_raster_format: str 187 | File format of the layover/shadow mask 188 | scratch_dir: str 189 | Temporary Directory 190 | shadow_dilation_size: int 191 | Layover/shadow mask dilation size of shadow pixels 192 | threshold_rdr2geo: float 193 | Iteration threshold for rdr2geo 194 | numiter_rdr2geo: int 195 | Number of max. iteration for rdr2geo object 196 | extraiter_rdr2geo: int 197 | Extra number of iteration for rdr2geo object 198 | lines_per_block_rdr2geo: int 199 | Lines per block for rdr2geo 200 | threshold_geo2rdr: float 201 | Iteration threshold for geo2rdr 202 | numiter_geo2rdr: int 203 | Number of max. iteration for geo2rdr object 204 | memory_mode: isce3.core.GeocodeMemoryMode 205 | Geocoding memory mode 206 | geocode_options: dict 207 | Keyword arguments to be passed to the geocode() function 208 | when map projection the layover/shadow mask 209 | 210 | Returns 211 | ------- 212 | slantrange_layover_shadow_mask_raster: isce3.io.Raster 213 | Layover/shadow-mask ISCE3 raster object in radar coordinates 214 | """ 215 | if doppler is None: 216 | doppler = isce3.core.LUT2d() 217 | 218 | # Run topo to get layover/shadow mask 219 | ellipsoid = isce3.core.Ellipsoid() 220 | grid_doppler = doppler 221 | rdr2geo_obj = isce3.geometry.Rdr2Geo( 222 | radar_grid, 223 | orbit, 224 | ellipsoid, 225 | grid_doppler, 226 | threshold=threshold_rdr2geo, 227 | numiter=numiter_rdr2geo, 228 | extraiter=extraiter_rdr2geo, 229 | lines_per_block=lines_per_block_rdr2geo, 230 | ) 231 | 232 | if shadow_dilation_size > 0: 233 | path_layover_shadow_mask_file = os.path.join(scratch_dir, 'layover_shadow_mask_slant_range.tif') 234 | slantrange_layover_shadow_mask_raster = isce3.io.Raster( 235 | path_layover_shadow_mask_file, radar_grid.width, radar_grid.length, 1, gdal.GDT_Byte, 'GTiff' 236 | ) 237 | else: 238 | slantrange_layover_shadow_mask_raster = isce3.io.Raster( 239 | 'layover_shadow_mask', radar_grid.width, radar_grid.length, 1, gdal.GDT_Byte, 'MEM' 240 | ) 241 | 242 | rdr2geo_obj.topo(dem_raster, layover_shadow_raster=slantrange_layover_shadow_mask_raster) 243 | 244 | if shadow_dilation_size > 1: 245 | """ 246 | constants from ISCE3: 247 | SHADOW_VALUE = 1; 248 | LAYOVER_VALUE = 2; 249 | LAYOVER_AND_SHADOW_VALUE = 3; 250 | We only want to dilate values 1 and 3 251 | """ 252 | 253 | # flush raster data to the disk 254 | slantrange_layover_shadow_mask_raster.close_dataset() 255 | del slantrange_layover_shadow_mask_raster 256 | 257 | # read layover/shadow mask 258 | gdal_ds = gdal.Open(path_layover_shadow_mask_file, gdal.GA_Update) 259 | gdal_band = gdal_ds.GetRasterBand(1) 260 | slantrange_layover_shadow_mask = gdal_band.ReadAsArray() 261 | 262 | # save layover pixels and substitute them with 0 263 | ind = np.where(slantrange_layover_shadow_mask == 2) 264 | slantrange_layover_shadow_mask[ind] = 0 265 | 266 | # perform grey dilation 267 | slantrange_layover_shadow_mask = ndimage.grey_dilation( 268 | slantrange_layover_shadow_mask, size=(shadow_dilation_size, shadow_dilation_size) 269 | ) 270 | 271 | # restore layover pixels 272 | slantrange_layover_shadow_mask[ind] = 2 273 | 274 | # write dilated layover/shadow mask 275 | gdal_band.WriteArray(slantrange_layover_shadow_mask) 276 | 277 | # flush updates to the disk 278 | gdal_band.FlushCache() 279 | gdal_band = None 280 | gdal_ds = None 281 | 282 | slantrange_layover_shadow_mask_raster = isce3.io.Raster(path_layover_shadow_mask_file) 283 | 284 | # geocode the layover/shadow mask 285 | geo = isce3.geocode.GeocodeFloat32() 286 | geo.orbit = orbit 287 | geo.ellipsoid = ellipsoid 288 | geo.doppler = doppler 289 | geo.threshold_geo2rdr = threshold_geo2rdr 290 | geo.numiter_geo2rdr = numiter_geo2rdr 291 | geo.data_interpolator = 'NEAREST' 292 | geo.geogrid( 293 | float(geogrid_in.start_x), 294 | float(geogrid_in.start_y), 295 | float(geogrid_in.spacing_x), 296 | float(geogrid_in.spacing_y), 297 | int(geogrid_in.width), 298 | int(geogrid_in.length), 299 | int(geogrid_in.epsg), 300 | ) 301 | 302 | geocoded_layover_shadow_mask_raster = isce3.io.Raster( 303 | filename_out, geogrid_in.width, geogrid_in.length, 1, gdal.GDT_Byte, output_raster_format 304 | ) 305 | 306 | if geocode_options is None: 307 | geocode_options = {} 308 | 309 | if memory_mode is not None: 310 | geocode_options['memory_mode'] = memory_mode 311 | 312 | geo.geocode( 313 | radar_grid=radar_grid, 314 | input_raster=slantrange_layover_shadow_mask_raster, 315 | output_raster=geocoded_layover_shadow_mask_raster, 316 | dem_raster=dem_raster, 317 | output_mode=isce3.geocode.GeocodeOutputMode.INTERP, 318 | **geocode_options, 319 | ) 320 | 321 | # flush data to the disk 322 | geocoded_layover_shadow_mask_raster.close_dataset() 323 | del geocoded_layover_shadow_mask_raster 324 | 325 | return slantrange_layover_shadow_mask_raster 326 | 327 | 328 | def _create_raster_obj( 329 | output_dir, 330 | product_id, 331 | layer_name, 332 | dtype, 333 | shape, 334 | radar_grid_file_dict, 335 | output_obj_list, 336 | ): 337 | """Create an ISCE3 raster object (GTiff) for a radar geometry layer. 338 | 339 | Parameters 340 | ---------- 341 | output_dir: str 342 | Output directory 343 | product_id: str 344 | Product ID 345 | dtype:: gdal.DataType 346 | GDAL data type 347 | shape: list 348 | Shape of the output raster 349 | radar_grid_file_dict: dict 350 | Dictionary that will hold the name of the output file 351 | referenced by the contents of `ds_hdf5` (dict key) 352 | output_obj_list: list 353 | Mutable list of output raster objects 354 | 355 | Returns 356 | ------- 357 | raster_obj : isce3.io.Raster 358 | ISCE3 raster object 359 | """ 360 | ds_name = f'{product_id}_{layer_name}' 361 | output_file = os.path.join(output_dir, ds_name) + '.tif' 362 | raster_obj = isce3.io.Raster(output_file, shape[2], shape[1], shape[0], dtype, 'GTiff') 363 | output_obj_list.append(raster_obj) 364 | radar_grid_file_dict[layer_name] = output_file 365 | return raster_obj 366 | 367 | 368 | def save_intermediate_geocode_files( 369 | geogrid, 370 | dem_interp_method_enum, 371 | product_id, 372 | output_dir, 373 | extension, 374 | dem_raster, 375 | radar_grid_file_dict, 376 | lookside, 377 | wavelength, 378 | orbit, 379 | doppler=None, 380 | ): 381 | if doppler is None: 382 | doppler = isce3.core.LUT2d() 383 | 384 | # FIXME: Computation of range slope is not merged to ISCE yet 385 | output_obj_list = [] 386 | layers_nbands = 1 387 | shape = [layers_nbands, geogrid.length, geogrid.width] 388 | names = [ 389 | LAYER_NAME_LOCAL_INCIDENCE_ANGLE, 390 | LAYER_NAME_INCIDENCE_ANGLE, 391 | LAYER_NAME_PROJECTION_ANGLE, 392 | LAYER_NAME_RTC_ANF_PROJECTION_ANGLE, 393 | # LAYER_NAME_RANGE_SLOPE, # FIXME 394 | LAYER_NAME_DEM, 395 | ] 396 | raster_objs = [] 397 | for name in names: 398 | raster_obj = _create_raster_obj( 399 | output_dir, 400 | product_id, 401 | name, 402 | gdal.GDT_Float32, 403 | shape, 404 | radar_grid_file_dict, 405 | output_obj_list, 406 | ) 407 | raster_objs.append(raster_obj) 408 | ( 409 | local_incidence_angle_raster, 410 | incidence_angle_raster, 411 | projection_angle_raster, 412 | rtc_anf_projection_angle_raster, 413 | # range_slope_raster, # FIXME 414 | interpolated_dem_raster, 415 | ) = raster_objs 416 | 417 | # TODO review this (Doppler)!!! 418 | # native_doppler = burst.doppler.lut2d 419 | native_doppler = doppler 420 | native_doppler.bounds_error = False 421 | grid_doppler = doppler 422 | grid_doppler.bounds_error = False 423 | 424 | isce3.geogrid.get_radar_grid( 425 | lookside, 426 | wavelength, 427 | dem_raster, 428 | geogrid, 429 | orbit, 430 | native_doppler, 431 | grid_doppler, 432 | dem_interp_method_enum, 433 | incidence_angle_raster=incidence_angle_raster, 434 | local_incidence_angle_raster=local_incidence_angle_raster, 435 | projection_angle_raster=projection_angle_raster, 436 | simulated_radar_brightness_raster=rtc_anf_projection_angle_raster, 437 | interpolated_dem_raster=interpolated_dem_raster, 438 | # range_slope_angle_raster=range_slope_raster, # FIXME 439 | ) 440 | for obj in output_obj_list: 441 | del obj 442 | 443 | 444 | def rtc(slc, geogrid, opts): 445 | # Common initializations 446 | t_start = time.time() 447 | output_dir = str(opts.output_dir) 448 | product_id = slc.id 449 | os.makedirs(output_dir, exist_ok=True) 450 | 451 | raster_format = 'GTiff' 452 | raster_extension = 'tif' 453 | 454 | # Filenames 455 | geo_filename = f'{output_dir}/{product_id}.{raster_extension}' 456 | nlooks_file = f'{output_dir}/{product_id}_{LAYER_NAME_NUMBER_OF_LOOKS}.{raster_extension}' 457 | rtc_anf_file = f'{output_dir}/{product_id}_{opts.layer_name_rtc_anf}.{raster_extension}' 458 | rtc_anf_gamma0_to_sigma0_file = ( 459 | f'{output_dir}/{product_id}_{LAYER_NAME_RTC_ANF_GAMMA0_TO_SIGMA0}.{raster_extension}' 460 | ) 461 | radar_grid = slc.radar_grid 462 | orbit = slc.orbit 463 | wavelength = slc.wavelength 464 | lookside = radar_grid.lookside 465 | 466 | dem_raster = isce3.io.Raster(opts.dem_path) 467 | ellipsoid = isce3.core.Ellipsoid() 468 | doppler = slc.doppler_centroid_grid 469 | exponent = 2 470 | 471 | x_snap = geogrid.spacing_x 472 | y_snap = geogrid.spacing_y 473 | geogrid.start_x = np.floor(float(geogrid.start_x) / x_snap) * x_snap 474 | geogrid.start_y = np.ceil(float(geogrid.start_y) / y_snap) * y_snap 475 | 476 | # geocoding optional arguments 477 | geocode_kwargs = {} 478 | layover_shadow_mask_geocode_kwargs = {} 479 | 480 | if isinstance(slc, SicdSlc): 481 | input_filename = slc.filepath.parent / (slc.filepath.stem + '_beta0.tif') 482 | slc.create_complex_beta0(input_filename) 483 | input_filename = str(input_filename) 484 | elif isinstance(slc, S1BurstSlc): 485 | input_filename = slc.filepath.parent / (slc.filepath.stem + '_beta0.tif') 486 | slc.create_complex_beta0(input_filename, flag_thermal_correction=opts.apply_thermal_noise) 487 | input_filename = str(input_filename) 488 | sub_swaths = slc.apply_valid_data_masking() 489 | geocode_kwargs['sub_swaths'] = sub_swaths 490 | layover_shadow_mask_geocode_kwargs['sub_swaths'] = sub_swaths 491 | else: 492 | input_filename = str(slc.filepath) 493 | 494 | layover_shadow_mask_file = f'{output_dir}/{product_id}_{LAYER_NAME_LAYOVER_SHADOW_MASK}.{raster_extension}' 495 | logger.info(f' computing layover shadow mask for {product_id}') 496 | radar_grid_layover_shadow_mask = radar_grid 497 | slantrange_layover_shadow_mask_raster = compute_layover_shadow_mask( 498 | radar_grid_layover_shadow_mask, 499 | orbit, 500 | geogrid, 501 | dem_raster, 502 | layover_shadow_mask_file, 503 | raster_format, 504 | output_dir, 505 | shadow_dilation_size=opts.shadow_dilation_size, 506 | threshold_rdr2geo=opts.rdr2geo_threshold, 507 | numiter_rdr2geo=opts.rdr2geo_numiter, 508 | threshold_geo2rdr=opts.geo2rdr_threshold, 509 | numiter_geo2rdr=opts.geo2rdr_numiter, 510 | memory_mode=opts.memory_mode_isce3, 511 | geocode_options=layover_shadow_mask_geocode_kwargs, 512 | doppler=doppler, 513 | ) 514 | logger.info(f'file saved: {layover_shadow_mask_file}') 515 | if opts.apply_shadow_masking: 516 | geocode_kwargs['input_layover_shadow_mask_raster'] = slantrange_layover_shadow_mask_raster 517 | 518 | out_geo_nlooks_obj = isce3.io.Raster(nlooks_file, geogrid.width, geogrid.length, 1, gdal.GDT_Float32, raster_format) 519 | out_geo_rtc_obj = isce3.io.Raster(rtc_anf_file, geogrid.width, geogrid.length, 1, gdal.GDT_Float32, raster_format) 520 | out_geo_rtc_gamma0_to_sigma0_obj = isce3.io.Raster( 521 | rtc_anf_gamma0_to_sigma0_file, geogrid.width, geogrid.length, 1, gdal.GDT_Float32, raster_format 522 | ) 523 | geocode_kwargs['out_geo_rtc_gamma0_to_sigma0'] = out_geo_rtc_gamma0_to_sigma0_obj 524 | if opts.apply_bistatic_delay or opts.apply_static_tropo: 525 | rg_lut, az_lut = compute_correction_lut( 526 | slc.source, 527 | dem_raster, 528 | output_dir, 529 | opts.correction_lut_range_spacing_in_meters, 530 | opts.correction_lut_azimuth_spacing_in_meters, 531 | opts.apply_bistatic_delay, 532 | opts.apply_static_tropo, 533 | ) 534 | geocode_kwargs['az_time_correction'] = az_lut 535 | if rg_lut is not None: 536 | geocode_kwargs['slant_range_correction'] = rg_lut 537 | 538 | rdr_raster = isce3.io.Raster(input_filename) 539 | # Generate output geocoded burst raster 540 | geo_raster = isce3.io.Raster( 541 | geo_filename, geogrid.width, geogrid.length, rdr_raster.num_bands, gdal.GDT_Float32, raster_format 542 | ) 543 | 544 | # init Geocode object depending on raster type 545 | if rdr_raster.datatype() == gdal.GDT_Float32: 546 | geo_obj = isce3.geocode.GeocodeFloat32() 547 | elif rdr_raster.datatype() == gdal.GDT_Float64: 548 | geo_obj = isce3.geocode.GeocodeFloat64() 549 | elif rdr_raster.datatype() == gdal.GDT_CFloat32: 550 | geo_obj = isce3.geocode.GeocodeCFloat32() 551 | elif rdr_raster.datatype() == gdal.GDT_CFloat64: 552 | geo_obj = isce3.geocode.GeocodeCFloat64() 553 | else: 554 | err_str = 'Unsupported raster type for geocoding' 555 | raise NotImplementedError(err_str) 556 | 557 | # init geocode members 558 | geo_obj.orbit = orbit 559 | geo_obj.ellipsoid = ellipsoid 560 | geo_obj.doppler = doppler 561 | geo_obj.threshold_geo2rdr = opts.geo2rdr_threshold 562 | geo_obj.numiter_geo2rdr = opts.geo2rdr_numiter 563 | 564 | # set data interpolator based on the geocode algorithm 565 | if opts.geocode_algorithm_isce3 == isce3.geocode.GeocodeOutputMode.INTERP: 566 | geo_obj.data_interpolator = opts.geocode_algorithm_isce3 567 | 568 | geo_obj.geogrid( 569 | geogrid.start_x, 570 | geogrid.start_y, 571 | geogrid.spacing_x, 572 | geogrid.spacing_y, 573 | geogrid.width, 574 | geogrid.length, 575 | geogrid.epsg, 576 | ) 577 | 578 | geo_obj.geocode( 579 | radar_grid=radar_grid, 580 | input_raster=rdr_raster, 581 | output_raster=geo_raster, 582 | dem_raster=dem_raster, 583 | output_mode=opts.geocode_algorithm_isce3, 584 | geogrid_upsampling=opts.geogrid_upsampling, 585 | flag_apply_rtc=opts.apply_rtc, 586 | input_terrain_radiometry=opts.input_terrain_radiometry_isce3, 587 | output_terrain_radiometry=opts.terrain_radiometry_isce3, 588 | exponent=exponent, 589 | rtc_min_value_db=opts.rtc_min_value_db, 590 | rtc_upsampling=opts.rtc_upsampling, 591 | rtc_algorithm=opts.rtc_algorithm_isce3, 592 | abs_cal_factor=opts.abs_cal_factor, 593 | flag_upsample_radar_grid=opts.upsample_radar_grid, 594 | clip_min=opts.clip_min, 595 | clip_max=opts.clip_max, 596 | out_geo_nlooks=out_geo_nlooks_obj, 597 | out_geo_rtc=out_geo_rtc_obj, 598 | rtc_area_beta_mode=opts.rtc_area_beta_mode_isce3, 599 | # out_geo_rtc_gamma0_to_sigma0=out_geo_rtc_gamma0_to_sigma0_obj, 600 | input_rtc=None, 601 | output_rtc=None, 602 | dem_interp_method=opts.dem_interpolation_method_isce3, 603 | memory_mode=opts.memory_mode_isce3, 604 | **geocode_kwargs, 605 | ) 606 | 607 | del geo_raster 608 | 609 | out_geo_nlooks_obj.close_dataset() 610 | del out_geo_nlooks_obj 611 | 612 | out_geo_rtc_obj.close_dataset() 613 | del out_geo_rtc_obj 614 | 615 | out_geo_rtc_gamma0_to_sigma0_obj.close_dataset() 616 | del out_geo_rtc_gamma0_to_sigma0_obj 617 | 618 | radar_grid_file_dict = {} 619 | save_intermediate_geocode_files( 620 | geogrid, 621 | opts.dem_interpolation_method_isce3, 622 | product_id, 623 | output_dir, 624 | raster_extension, 625 | dem_raster, 626 | radar_grid_file_dict, 627 | lookside, 628 | wavelength, 629 | orbit, 630 | doppler=doppler, 631 | ) 632 | t_end = time.time() 633 | logger.info(f'elapsed time: {t_end - t_start}') 634 | 635 | 636 | def pfa_prototype_geocode(sicd, geogrid, dem_path, output_dir): 637 | interp_method = isce3.core.DataInterpMethod.BIQUINTIC 638 | sigma0_data = sicd.load_scaled_data('sigma0', power=True) 639 | slc_lut = isce3.core.LUT2d( 640 | np.arange(sigma0_data.shape[1]), np.arange(sigma0_data.shape[0]), sigma0_data, interp_method 641 | ) 642 | assert geogrid.epsg == 4326, 'Only EPSG:4326 is supported for PFA prototype geocoding' 643 | ll2ecef = pyproj.Transformer.from_crs('EPSG:4326', 'EPSG:4978', always_xy=True) 644 | dem_raster = isce3.io.Raster(str(dem_path)) 645 | dem = isce3.geometry.DEMInterpolator() 646 | dem.load_dem(dem_raster) 647 | dem.interp_method = interp_method 648 | output = np.zeros((geogrid.length, geogrid.width), dtype=np.float32) 649 | mask = np.zeros((geogrid.length, geogrid.width), dtype=bool) 650 | 651 | n_iters = geogrid.width * geogrid.length 652 | for i, j in tqdm(itertools.product(range(geogrid.width), range(geogrid.length)), total=n_iters): 653 | x = geogrid.start_x + (i * geogrid.spacing_x) 654 | y = geogrid.start_y + (j * geogrid.spacing_y) 655 | hae = dem.interpolate_lonlat(np.deg2rad(x), np.deg2rad(y)) # ISCE3 expects lat/lon to be in radians! 656 | ecef_x, ecef_y, ecef_z = ll2ecef.transform(x, y, hae) 657 | row, col = sicd.geo2rowcol(np.array([(ecef_x, ecef_y, ecef_z)]))[0] 658 | if slc_lut.contains(row, col): 659 | output[j, i] = slc_lut.eval(row, col) 660 | mask[j, i] = 1 661 | 662 | output[mask == 0] = np.nan 663 | output_path = output_dir / f'{sicd.id}_{sicd.polarization}.tif' 664 | driver = gdal.GetDriverByName('GTiff') 665 | out_ds = driver.Create(str(output_path), geogrid.width, geogrid.length, 1, gdal.GDT_Float32) 666 | # account for pixel as area 667 | start_x = geogrid.start_x - (geogrid.spacing_x / 2) 668 | start_y = geogrid.start_y + (geogrid.spacing_y / 2) 669 | out_ds.SetGeoTransform([start_x, geogrid.spacing_x, 0, start_y, 0, geogrid.spacing_y]) 670 | out_ds.SetProjection(pyproj.CRS(geogrid.epsg).to_wkt()) 671 | out_ds.GetRasterBand(1).WriteArray(output) 672 | out_ds.GetRasterBand(1).SetNoDataValue(np.nan) 673 | out_ds.SetMetadata({'AREA_OR_POINT': 'Area'}) 674 | out_ds = None 675 | 676 | local_epsg = get_point_epsg(geogrid.start_y, geogrid.start_x) 677 | gdal.Warp(str(output_path), str(output_path), dstSRS=f'EPSG:{local_epsg}', format='GTiff') 678 | -------------------------------------------------------------------------------- /src/multirtc/define_geogrid.py: -------------------------------------------------------------------------------- 1 | import isce3 2 | import numpy as np 3 | from shapely.geometry import Polygon 4 | 5 | 6 | def get_point_epsg(lat: float, lon: float) -> int: 7 | """Determine the best EPSG code for a given latitude and longitude. 8 | Returns the local UTM zone for latitudes between +/-75 degrees and 9 | polar/antartic stereographic for latitudes outside that range. 10 | 11 | Args: 12 | lat: Latitude in degrees. 13 | lon: Longitude in degrees. 14 | 15 | Returns: 16 | EPSG code for the specified latitude and longitude. 17 | """ 18 | if (lon >= 180.0) or (lon <= -180.0): 19 | lon = (lon + 180.0) % 360.0 - 180.0 20 | if lat >= 75.0: 21 | epsg = 3413 22 | elif lat <= -75.0: 23 | epsg = 3031 24 | elif lat > 0: 25 | epsg = 32601 + int(np.round((lon + 177) / 6.0)) 26 | elif lat < 0: 27 | epsg = 32701 + int(np.round((lon + 177) / 6.0)) 28 | else: 29 | raise ValueError(f'Could not determine EPSG for {lon}, {lat}') 30 | assert (32600 <= epsg <= 32761) or epsg in [3031, 3413], 'Computed EPSG is out of range' 31 | return epsg 32 | 33 | 34 | def snap_coord(val: float, snap: float, round_func: callable) -> float: 35 | """ 36 | Returns the snapped version of the input value 37 | 38 | Args: 39 | val : value to snap 40 | snap : snapping step 41 | round_func : function pointer to round, ceil, or floor 42 | 43 | Returns: 44 | snapped value of `val` by `snap` 45 | """ 46 | snapped_value = round_func(float(val) / snap) * snap 47 | return snapped_value 48 | 49 | 50 | def grid_size(stop: float, start: float, size: float): 51 | """ 52 | Get number of grid points based on start, end, and grid size inputs 53 | 54 | Args: 55 | stop: End value of grid 56 | start: Start value of grid 57 | size: Grid size in same units as start and stop 58 | 59 | Returns: 60 | Number of grid points between start and stop 61 | """ 62 | return int(np.round(np.abs((stop - start) / size))) 63 | 64 | 65 | def snap_geogrid( 66 | geogrid: isce3.product.GeoGridParameters, x_snap: float, y_snap: float 67 | ) -> isce3.product.GeoGridParameters: 68 | """ 69 | Snap geogrid based on user-defined snapping values 70 | 71 | Args: 72 | geogrid: ISCE3 object definining the geogrid 73 | x_snap: Snap value along X-direction 74 | y_snap: Snap value along Y-direction 75 | 76 | Returns: 77 | ISCE3 object containing the snapped geogrid 78 | """ 79 | xmax = geogrid.start_x + geogrid.width * geogrid.spacing_x 80 | ymin = geogrid.start_y + geogrid.length * geogrid.spacing_y 81 | 82 | geogrid.start_x = snap_coord(geogrid.start_x, x_snap, np.floor) 83 | end_x = snap_coord(xmax, x_snap, np.ceil) 84 | geogrid.width = grid_size(end_x, geogrid.start_x, geogrid.spacing_x) 85 | 86 | geogrid.start_y = snap_coord(geogrid.start_y, y_snap, np.ceil) 87 | end_y = snap_coord(ymin, y_snap, np.floor) 88 | geogrid.length = grid_size(end_y, geogrid.start_y, geogrid.spacing_y) 89 | return geogrid 90 | 91 | 92 | def get_geogrid_poly(geogrid: isce3.product.GeoGridParameters) -> Polygon: 93 | """ 94 | Create a polygon from a geogrid object 95 | 96 | Args: 97 | geogrid: ISCE3 object defining the geogrid 98 | 99 | Returns: 100 | Shapely Polygon representing the geogrid area 101 | """ 102 | new_maxx = geogrid.start_x + (geogrid.width * geogrid.spacing_x) 103 | new_miny = geogrid.start_y + (geogrid.length * geogrid.spacing_y) 104 | points = [ 105 | [geogrid.start_x, geogrid.start_y], 106 | [geogrid.start_x, new_miny], 107 | [new_maxx, new_miny], 108 | [new_maxx, geogrid.start_y], 109 | ] 110 | poly = Polygon(points) 111 | return poly 112 | 113 | 114 | def generate_geogrids(slc, spacing_meters: int, epsg: int) -> isce3.product.GeoGridParameters: 115 | """Compute a geogrid based on the radar grid of the SLC and the specified spacing. 116 | 117 | Args: 118 | slc: Slc-derived object containing radar grid, orbit, and doppler centroid grid. 119 | spacing_meters: Spacing in meters for the geogrid. 120 | epsg: EPSG code for the coordinate reference system. 121 | 122 | Returns: 123 | A geogrid object with the specified spacing. 124 | """ 125 | x_spacing = spacing_meters 126 | y_spacing = -1 * np.abs(spacing_meters) 127 | geogrid = isce3.product.bbox_to_geogrid( 128 | slc.radar_grid, slc.orbit, slc.doppler_centroid_grid, x_spacing, y_spacing, epsg 129 | ) 130 | geogrid_snapped = snap_geogrid(geogrid, geogrid.spacing_x, geogrid.spacing_y) 131 | return geogrid_snapped 132 | -------------------------------------------------------------------------------- /src/multirtc/dem.py: -------------------------------------------------------------------------------- 1 | from concurrent.futures import ThreadPoolExecutor 2 | from itertools import product 3 | from pathlib import Path 4 | 5 | import numpy as np 6 | import shapely 7 | from hyp3lib.fetch import download_file 8 | from osgeo import gdal 9 | from shapely.geometry import LinearRing, Polygon, box 10 | 11 | 12 | gdal.UseExceptions() 13 | URL = 'https://nisar.asf.earthdatacloud.nasa.gov/STATIC/DEM/v1.1/EPSG4326' 14 | 15 | 16 | def check_antimeridean(poly: Polygon) -> list[Polygon]: 17 | """Check if the polygon crosses the antimeridian and split the polygon if it does. 18 | 19 | Args: 20 | poly: Polygon object to check for antimeridian crossing. 21 | 22 | Returns: 23 | List of Polygon objects, split if necessary. 24 | """ 25 | x_min, _, x_max, _ = poly.bounds 26 | 27 | # Check anitmeridean crossing 28 | if (x_max - x_min > 180.0) or (x_min <= 180.0 <= x_max): 29 | dateline = shapely.wkt.loads('LINESTRING( 180.0 -90.0, 180.0 90.0)') 30 | 31 | # build new polygon with all longitudes between 0 and 360 32 | x, y = poly.exterior.coords.xy 33 | new_x = (k + (k <= 0.0) * 360 for k in x) 34 | new_ring = LinearRing(zip(new_x, y)) 35 | 36 | # Split input polygon 37 | # (https://gis.stackexchange.com/questions/232771/splitting-polygon-by-linestring-in-geodjango_) 38 | merged_lines = shapely.ops.linemerge([dateline, new_ring]) 39 | border_lines = shapely.ops.unary_union(merged_lines) 40 | decomp = shapely.ops.polygonize(border_lines) 41 | 42 | polys = list(decomp) 43 | 44 | for polygon_count in range(len(polys)): 45 | x, y = polys[polygon_count].exterior.coords.xy 46 | # if there are no longitude values above 180, continue 47 | if not any([k > 180 for k in x]): 48 | continue 49 | 50 | # otherwise, wrap longitude values down by 360 degrees 51 | x_wrapped_minus_360 = np.asarray(x) - 360 52 | polys[polygon_count] = Polygon(zip(x_wrapped_minus_360, y)) 53 | 54 | else: 55 | # If dateline is not crossed, treat input poly as list 56 | polys = [poly] 57 | 58 | return polys 59 | 60 | 61 | def get_dem_granule_url(lat: int, lon: int) -> str: 62 | """Generate the URL for the OPERA DEM granule based on latitude and longitude. 63 | 64 | Args: 65 | lat: Latitude in degrees. 66 | lon: Longitude in degrees. 67 | 68 | Returns: 69 | URL string for the DEM granule. 70 | """ 71 | lat_tens = np.floor_divide(lat, 10) * 10 72 | lat_cardinal = 'S' if lat_tens < 0 else 'N' 73 | 74 | lon_tens = np.floor_divide(lon, 20) * 20 75 | lon_cardinal = 'W' if lon_tens < 0 else 'E' 76 | 77 | prefix = f'{lat_cardinal}{np.abs(lat_tens):02d}_{lon_cardinal}{np.abs(lon_tens):03d}' 78 | filename = f'DEM_{lat_cardinal}{np.abs(lat):02d}_00_{lon_cardinal}{np.abs(lon):03d}_00.tif' 79 | file_url = f'{URL}/{prefix}/{filename}' 80 | return file_url 81 | 82 | 83 | def get_latlon_pairs(polygon: Polygon) -> list[tuple[float, float]]: 84 | """Get latitude and longitude pairs for the bounding box of a polygon. 85 | 86 | Args: 87 | polygon: Polygon object representing the area of interest. 88 | 89 | Returns: 90 | List of tuples containing latitude and longitude pairs for each point of the bounding box. 91 | """ 92 | minx, miny, maxx, maxy = polygon.bounds 93 | lats = np.arange(np.floor(miny), np.floor(maxy) + 1).astype(int) 94 | lons = np.arange(np.floor(minx), np.floor(maxx) + 1).astype(int) 95 | return list(product(lats, lons)) 96 | 97 | 98 | def download_opera_dem_for_footprint(output_path: Path, footprint: Polygon, buffer: float = 0.2) -> None: 99 | """ 100 | Download the OPERA DEM for a given footprint and save it to the specified output path. 101 | 102 | Args: 103 | output_path: Path where the DEM will be saved. 104 | footprint: Polygon representing the area of interest. 105 | buffer: Buffer distance in degrees to extend the footprint. 106 | """ 107 | output_dir = output_path.parent 108 | if output_path.exists(): 109 | return output_path 110 | 111 | footprint = box(*footprint.buffer(buffer).bounds) 112 | footprints = check_antimeridean(footprint) 113 | latlon_pairs = [] 114 | for footprint in footprints: 115 | latlon_pairs += get_latlon_pairs(footprint) 116 | urls = [get_dem_granule_url(lat, lon) for lat, lon in latlon_pairs] 117 | 118 | with ThreadPoolExecutor(max_workers=4) as executor: 119 | executor.map(lambda url: download_file(url, str(output_dir)), urls) 120 | 121 | vrt_filepath = output_dir / 'dem.vrt' 122 | input_files = [str(output_dir / Path(url).name) for url in urls] 123 | gdal.BuildVRT(str(output_dir / 'dem.vrt'), input_files) 124 | ds = gdal.Open(str(vrt_filepath), gdal.GA_ReadOnly) 125 | gdal.Translate(str(output_path), ds, format='GTiff') 126 | 127 | ds = None 128 | [Path(f).unlink() for f in input_files + [vrt_filepath]] 129 | -------------------------------------------------------------------------------- /src/multirtc/multirtc.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | from burst2safe.burst2safe import burst2safe 6 | from s1reader.s1_orbit import retrieve_orbit_file 7 | 8 | from multirtc import dem 9 | from multirtc.base import Slc 10 | from multirtc.create_rtc import pfa_prototype_geocode, rtc 11 | from multirtc.rtc_options import RtcOptions 12 | from multirtc.sentinel1 import S1BurstSlc 13 | from multirtc.sicd import SicdPfaSlc, SicdRzdSlc 14 | 15 | 16 | SUPPORTED = ['S1', 'UMBRA', 'CAPELLA', 'ICEYE'] 17 | 18 | 19 | def prep_dirs(work_dir: Optional[Path] = None) -> tuple[Path, Path]: 20 | """Prepare input and output directories for processing. 21 | 22 | Args: 23 | work_dir: Working directory. If None, current working directory is used. 24 | 25 | Returns: 26 | Tuple of input and output directories. 27 | """ 28 | if work_dir is None: 29 | work_dir = Path.cwd() 30 | input_dir = work_dir / 'input' 31 | output_dir = work_dir / 'output' 32 | [d.mkdir(parents=True, exist_ok=True) for d in [input_dir, output_dir]] 33 | return input_dir, output_dir 34 | 35 | 36 | def get_slc(platform: str, granule: str, input_dir: Path) -> Slc: 37 | """ 38 | Get the SLC object for the specified platform and granule. 39 | 40 | Args: 41 | platform: Platform type (e.g., 'UMBRA'). 42 | granule: Granule name if data is available in ASF archive, or filename if granule is already downloaded. 43 | input_dir: Directory containing the input data. 44 | 45 | Returns: 46 | Slc subclass object for the specified platform and granule. 47 | """ 48 | if platform == 'S1': 49 | safe_path = burst2safe(granules=[granule], all_anns=True, work_dir=input_dir) 50 | orbit_path = Path(retrieve_orbit_file(safe_path.name, str(input_dir), concatenate=True)) 51 | slc = S1BurstSlc(safe_path, orbit_path, granule) 52 | elif platform in ['CAPELLA', 'ICEYE', 'UMBRA']: 53 | sicd_class = {'CAPELLA': SicdRzdSlc, 'ICEYE': SicdRzdSlc, 'UMBRA': SicdPfaSlc}[platform] 54 | granule_path = input_dir / granule 55 | if not granule_path.exists(): 56 | raise FileNotFoundError(f'SICD must be present in input dir {input_dir} for processing.') 57 | slc = sicd_class(granule_path) 58 | else: 59 | raise ValueError(f'Unsupported platform {platform}. Supported platforms are {",".join(SUPPORTED)}.') 60 | return slc 61 | 62 | 63 | def run_multirtc(platform: str, granule: str, resolution: int, work_dir: Path) -> None: 64 | """Create an RTC or Geocoded dataset using the OPERA algorithm. 65 | 66 | Args: 67 | platform: Platform type (e.g., 'UMBRA'). 68 | granule: Granule name if data is available in ASF archive, or filename if granule is already downloaded. 69 | resolution: Resolution of the output RTC (in meters). 70 | work_dir: Working directory for processing. 71 | """ 72 | input_dir, output_dir = prep_dirs(work_dir) 73 | slc = get_slc(platform, granule, input_dir) 74 | dem_path = input_dir / 'dem.tif' 75 | dem.download_opera_dem_for_footprint(dem_path, slc.footprint) 76 | geogrid = slc.create_geogrid(spacing_meters=resolution) 77 | if slc.supports_rtc: 78 | opts = RtcOptions( 79 | dem_path=str(dem_path), 80 | output_dir=str(output_dir), 81 | resolution=resolution, 82 | apply_bistatic_delay=slc.supports_bistatic_delay, 83 | apply_static_tropo=slc.supports_static_tropo, 84 | ) 85 | rtc(slc, geogrid, opts) 86 | else: 87 | pfa_prototype_geocode(slc, geogrid, dem_path, output_dir) 88 | 89 | 90 | def main(): 91 | """Create a RTC or geocoded dataset for a multiple satellite platforms 92 | 93 | Example command: 94 | multirtc UMBRA umbra_image.ntif --resolution 40 95 | """ 96 | parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter) 97 | parser.add_argument('platform', choices=SUPPORTED, help='Platform to create RTC for') 98 | parser.add_argument('granule', help='Data granule to create an RTC for.') 99 | parser.add_argument('--resolution', default=30, type=float, help='Resolution of the output RTC (m)') 100 | parser.add_argument('--work-dir', type=Path, default=None, help='Working directory for processing') 101 | args = parser.parse_args() 102 | 103 | if args.work_dir is None: 104 | args.work_dir = Path.cwd() 105 | run_multirtc(args.platform, args.granule, args.resolution, args.work_dir) 106 | 107 | 108 | if __name__ == '__main__': 109 | main() 110 | -------------------------------------------------------------------------------- /src/multirtc/rtc_options.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import isce3 4 | import numpy as np 5 | 6 | 7 | @dataclass 8 | class RtcOptions: 9 | """Options for RTC processing using ISCE3.""" 10 | 11 | output_dir: str 12 | dem_path: str 13 | apply_rtc: bool = True 14 | apply_thermal_noise: bool = True 15 | apply_abs_rad: bool = True 16 | apply_bistatic_delay: bool = True 17 | apply_static_tropo: bool = True 18 | apply_valid_samples_sub_swath_masking: bool = True 19 | apply_shadow_masking: bool = True 20 | dem_interpolation_method: str = 'biquintic' 21 | geocode_algorithm: str = 'area_projection' # 'area_projection' or 'interp' 22 | correction_lut_azimuth_spacing_in_meters: int = 120 23 | correction_lut_range_spacing_in_meters: int = 120 24 | memory_mode: str = 'single_block' 25 | geogrid_upsampling: int = 1 26 | shadow_dilation_size: int = 0 27 | abs_cal_factor: int = 1 28 | clip_min: float = np.nan 29 | clip_max: float = np.nan 30 | upsample_radar_grid: bool = False 31 | terrain_radiometry: str = 'gamma0' # 'gamma0' or 'sigma0' 32 | rtc_algorithm_type: str = 'area_projection' # 'area_projection' or 'bilinear_distribution' 33 | input_terrain_radiometry: str = 'beta0' 34 | rtc_min_value_db: int = -30.0 35 | rtc_upsampling: int = 2 36 | rtc_area_beta_mode: str = 'auto' 37 | geo2rdr_threshold: float = 1.0e-7 38 | geo2rdr_numiter: int = 50 39 | rdr2geo_threshold: float = 1.0e-7 40 | rdr2geo_numiter: int = 25 41 | output_epsg: int = None 42 | resolution: int = 30 43 | 44 | def __post_init__(self): 45 | if not self.apply_rtc: 46 | if self.save_rtc_anf: 47 | raise ValueError('RTC ANF flags are only available with RTC enabled') 48 | if self.save_rtc_anf_gamma0_to_sigma0: 49 | raise ValueError('RTC ANF gamma0 to sigma0 flags are only available with RTC enabled') 50 | 51 | if self.terrain_radiometry == 'sigma0' and self.save_rtc_anf_gamma0_to_sigma0: 52 | raise ValueError('RTC ANF gamma0 to sigma0 flags are only available with output type set to gamma0') 53 | 54 | if self.apply_rtc: 55 | self.layer_name_rtc_anf = f'rtc_anf_{self.terrain_radiometry}_to_{self.input_terrain_radiometry}' 56 | else: 57 | self.layer_name_rtc_anf = '' 58 | 59 | if self.dem_interpolation_method == 'biquintic': 60 | self.dem_interpolation_method_isce3 = isce3.core.DataInterpMethod.BIQUINTIC 61 | else: 62 | raise ValueError(f'Invalid DEM interpolation method: {self.dem_interpolation_method}') 63 | 64 | if self.geocode_algorithm == 'area_projection': 65 | self.geocode_algorithm_isce3 = isce3.geocode.GeocodeOutputMode.AREA_PROJECTION 66 | elif self.geocode_algorithm == 'interp': 67 | self.geocode_algorithm_isce3 = isce3.geocode.GeocodeOutputMode.INTERP 68 | else: 69 | raise ValueError(f'Invalid geocode algorithm: {self.geocode_algorithm}') 70 | 71 | if self.memory_mode == 'single_block': 72 | self.memory_mode_isce3 = isce3.core.GeocodeMemoryMode.SingleBlock 73 | else: 74 | raise ValueError(f'Invalid memory mode: {self.memory_mode}') 75 | 76 | if self.rtc_algorithm_type == 'bilinear_distribution': 77 | self.rtc_algorithm_isce3 = isce3.geometry.RtcAlgorithm.RTC_BILINEAR_DISTRIBUTION 78 | elif self.rtc_algorithm_type == 'area_projection': 79 | self.rtc_algorithm_isce3 = isce3.geometry.RtcAlgorithm.RTC_AREA_PROJECTION 80 | else: 81 | raise ValueError(f'Invalid RTC algorithm: {self.rtc_algorithm_type}') 82 | 83 | if self.input_terrain_radiometry == 'sigma0': 84 | self.input_terrain_radiometry_isce3 = isce3.geometry.RtcInputTerrainRadiometry.SIGMA_NAUGHT_ELLIPSOID 85 | elif self.input_terrain_radiometry == 'beta0': 86 | self.input_terrain_radiometry_isce3 = isce3.geometry.RtcInputTerrainRadiometry.BETA_NAUGHT 87 | else: 88 | raise ValueError(f'Invalid input terrain radiometry: {self.input_terrain_radiometry}') 89 | 90 | if self.terrain_radiometry == 'sigma0': 91 | self.terrain_radiometry_isce3 = isce3.geometry.RtcOutputTerrainRadiometry.SIGMA_NAUGHT 92 | elif self.terrain_radiometry == 'gamma0': 93 | self.terrain_radiometry_isce3 = isce3.geometry.RtcOutputTerrainRadiometry.GAMMA_NAUGHT 94 | else: 95 | raise ValueError(f'Invalid terrain radiometry: {self.terrain_radiometry}') 96 | 97 | if self.apply_rtc: 98 | self.layer_name_rtc_anf = f'rtc_anf_{self.terrain_radiometry}_to_{self.input_terrain_radiometry}' 99 | else: 100 | self.layer_name_rtc_anf = '' 101 | 102 | if self.rtc_area_beta_mode == 'pixel_area': 103 | self.rtc_area_beta_mode_isce3 = isce3.geometry.RtcAreaBetaMode.PIXEL_AREA 104 | elif self.rtc_area_beta_mode == 'projection_angle': 105 | self.rtc_area_beta_mode_isce3 = isce3.geometry.RtcAreaBetaMode.PROJECTION_ANGLE 106 | elif self.rtc_area_beta_mode == 'auto' or self.rtc_area_beta_mode is None: 107 | self.rtc_area_beta_mode_isce3 = isce3.geometry.RtcAreaBetaMode.AUTO 108 | else: 109 | raise ValueError(f'Invalid area beta mode: {self.rtc_area_beta_mode}') 110 | -------------------------------------------------------------------------------- /src/multirtc/sentinel1.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import isce3 4 | import numpy as np 5 | import s1reader 6 | from osgeo import gdal 7 | 8 | from multirtc import define_geogrid 9 | from multirtc.base import Slc, from_isce_datetime, to_isce_datetime 10 | 11 | 12 | gdal.UseExceptions() 13 | 14 | 15 | class S1BurstSlc(Slc): 16 | """Class representing a Sentinel-1 burst SLC (Single Look Complex) product.""" 17 | 18 | def __init__(self, safe_path: Path, orbit_path: Path, burst_name: str): 19 | _, burst_id, swath, _, polarization, _ = burst_name.split('_') 20 | burst_id = int(burst_id) 21 | swath_num = int(swath[2]) 22 | bursts = s1reader.load_bursts(str(safe_path), str(orbit_path), swath_num, polarization) 23 | burst = [b for b in bursts if str(b.burst_id).endswith(f'{burst_id}_{swath.lower()}')][0] 24 | del bursts 25 | vrt_path = safe_path.parent / f'{burst_name}.vrt' 26 | burst.slc_to_vrt_file(vrt_path) 27 | self.id = burst_name 28 | self.filepath = vrt_path 29 | self.footprint = burst.border[0] 30 | self.center = burst.center 31 | self.local_epsg = define_geogrid.get_point_epsg(self.center.y, self.center.x) 32 | self.lookside = 'right' 33 | self.wavelength = burst.wavelength 34 | self.polarization = burst.polarization 35 | self.shape = burst.shape 36 | self.range_pixel_spacing = burst.range_pixel_spacing 37 | self.reference_time = from_isce_datetime(burst.orbit.reference_epoch) 38 | self.sensing_start = (burst.sensing_start - self.reference_time).total_seconds() 39 | self.starting_range = burst.starting_range 40 | self.prf = 1 / burst.azimuth_time_interval 41 | self.orbit = burst.orbit 42 | self.doppler_centroid_grid = isce3.core.LUT2d() 43 | self.radar_grid = isce3.product.RadarGridParameters( 44 | sensing_start=self.sensing_start, 45 | wavelength=self.wavelength, 46 | prf=self.prf, 47 | starting_range=self.starting_range, 48 | range_pixel_spacing=self.range_pixel_spacing, 49 | lookside=isce3.core.LookSide.Right, 50 | length=self.shape[0], 51 | width=self.shape[1], 52 | ref_epoch=to_isce_datetime(self.reference_time), 53 | ) 54 | self.first_valid_line = burst.first_valid_line 55 | self.last_valid_line = burst.last_valid_line 56 | self.first_valid_sample = burst.first_valid_sample 57 | self.last_valid_sample = burst.last_valid_sample 58 | self.source = burst 59 | self.supports_rtc = True 60 | self.supports_bistatic_delay = True 61 | self.supports_static_tropo = True 62 | 63 | def create_geogrid(self, spacing_meters: int) -> isce3.product.GeoGridParameters: 64 | return define_geogrid.generate_geogrids(self, spacing_meters, self.local_epsg) 65 | 66 | def apply_valid_data_masking(self) -> isce3.product.SubSwaths: 67 | """Extract burst boundaries and create sub_swaths object to mask invalid radar samples. 68 | 69 | Returns: 70 | SubSwaths object with valid samples set according to the burst boundaries. 71 | """ 72 | n_subswaths = 1 73 | sub_swaths = isce3.product.SubSwaths(self.radar_grid.length, self.radar_grid.width, n_subswaths) 74 | last_range_sample = min([self.last_valid_sample, self.radar_grid.width]) 75 | valid_samples_sub_swath = np.repeat( 76 | [[self.first_valid_sample, last_range_sample + 1]], self.radar_grid.length, axis=0 77 | ) 78 | for i in range(self.first_valid_line): 79 | valid_samples_sub_swath[i, :] = 0 80 | for i in range(self.last_valid_line, self.radar_grid.length): 81 | valid_samples_sub_swath[i, :] = 0 82 | 83 | sub_swaths.set_valid_samples_array(1, valid_samples_sub_swath) 84 | return sub_swaths 85 | 86 | def create_complex_beta0(self, outpath: Path, flag_thermal_correction: bool = True) -> None: 87 | """Apply conversion to beta0 and optionally apply a thermal noise correction. 88 | 89 | Args: 90 | outpath: Path to save the corrected beta0 image. 91 | flag_thermal_correction: If True, apply thermal noise correction using the LUT from the source burst. 92 | """ 93 | # Load the SLC of the burst 94 | slc_gdal_ds = gdal.Open(str(self.filepath)) 95 | arr_slc_from = slc_gdal_ds.ReadAsArray() 96 | 97 | # Apply thermal noise correction 98 | if flag_thermal_correction: 99 | corrected_image = np.abs(arr_slc_from) ** 2 - self.source.thermal_noise_lut 100 | min_backscatter = 0 101 | max_backscatter = None 102 | corrected_image = np.clip(corrected_image, min_backscatter, max_backscatter) 103 | else: 104 | corrected_image = np.abs(arr_slc_from) ** 2 105 | 106 | # Apply absolute radiometric correction 107 | corrected_image = corrected_image / self.source.burst_calibration.beta_naught**2 108 | 109 | factor_mag = np.sqrt(corrected_image) / np.abs(arr_slc_from) 110 | factor_mag[np.isnan(factor_mag)] = 0.0 111 | corrected_image = arr_slc_from * factor_mag 112 | dtype = gdal.GDT_CFloat32 113 | 114 | # Save the corrected image 115 | drvout = gdal.GetDriverByName('GTiff') 116 | raster_out = drvout.Create(outpath, self.shape[1], self.shape[0], 1, dtype) 117 | band_out = raster_out.GetRasterBand(1) 118 | band_out.WriteArray(corrected_image) 119 | band_out.FlushCache() 120 | del band_out 121 | -------------------------------------------------------------------------------- /src/multirtc/sicd.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | import isce3 6 | import numpy as np 7 | import pyproj 8 | from numpy.polynomial.polynomial import polyval2d 9 | from osgeo import gdal 10 | from sarpy.io.complex.sicd import SICDReader 11 | from shapely.geometry import Point, Polygon 12 | 13 | from multirtc import define_geogrid 14 | from multirtc.base import Slc, print_wkt, to_isce_datetime 15 | 16 | 17 | def check_poly_order(poly): 18 | assert len(poly.Coefs) == poly.order1 + 1, 'Polynomial order does not match number of coefficients' 19 | 20 | 21 | class SicdSlc: 22 | """Base class for SICD SLCs.""" 23 | 24 | def __init__(self, sicd_path: Path): 25 | self.reader = SICDReader(str(sicd_path.expanduser().resolve())) 26 | sicd = self.reader.get_sicds_as_tuple()[0] 27 | self.source = sicd 28 | self.id = Path(sicd_path).with_suffix('').name 29 | self.filepath = Path(sicd_path) 30 | self.footprint = Polygon([(ic.Lon, ic.Lat) for ic in sicd.GeoData.ImageCorners]) 31 | self.center = Point(sicd.GeoData.SCP.LLH.Lon, sicd.GeoData.SCP.LLH.Lat) 32 | self.local_epsg = define_geogrid.get_point_epsg(self.center.y, self.center.x) 33 | self.scp_hae = sicd.GeoData.SCP.LLH.HAE 34 | self.lookside = 'right' if sicd.SCPCOA.SideOfTrack == 'R' else 'left' 35 | center_frequency = sicd.RadarCollection.TxFrequency.Min + sicd.RadarCollection.TxFrequency.Max / 2 36 | self.wavelength = isce3.core.speed_of_light / center_frequency 37 | self.polarization = sicd.RadarCollection.RcvChannels[0].TxRcvPolarization.replace(':', '') 38 | self.shape = (sicd.ImageData.NumRows, sicd.ImageData.NumCols) 39 | self.spacing = (sicd.Grid.Row.SS, sicd.Grid.Col.SS) 40 | self.scp_index = (sicd.ImageData.SCPPixel.Row, sicd.ImageData.SCPPixel.Col) 41 | self.range_pixel_spacing = sicd.Grid.Row.SS 42 | self.reference_time = sicd.Timeline.CollectStart.item() 43 | self.shift = ( 44 | sicd.ImageData.SCPPixel.Row - sicd.ImageData.FirstRow, 45 | sicd.ImageData.SCPPixel.Col - sicd.ImageData.FirstCol, 46 | ) 47 | self.arp_pos_poly = sicd.Position.ARPPoly 48 | self.raw_time_coa_poly = sicd.Grid.TimeCOAPoly 49 | self.arp_pos = sicd.SCPCOA.ARPPos.get_array() 50 | self.scp_pos = sicd.GeoData.SCP.ECF.get_array() 51 | self.look_angle = int(sicd.SCPCOA.AzimAng + 180) % 360 52 | self.beta0 = sicd.Radiometric.BetaZeroSFPoly 53 | self.sigma0 = sicd.Radiometric.SigmaZeroSFPoly 54 | self.supports_bistatic_delay = False 55 | self.supports_static_tropo = False 56 | 57 | def get_xrow_ycol( 58 | self, rowrange: Optional[tuple] = None, colrange: Optional[tuple] = None 59 | ) -> tuple[np.ndarray, np.ndarray]: 60 | """Calculate xrow and ycol index arrrays. 61 | 62 | Args: 63 | rowrange: Optional tuple specifying the range of rows (start, end). 64 | colrange: Optional tuple specifying the range of columns (start, end). 65 | 66 | Returns: 67 | Two 2D numpy arrays, xrow and ycol, representing the row and column indices 68 | adjusted by the SCP index and scaled by the spacing. 69 | """ 70 | rowlen = self.shape[0] if rowrange is None else rowrange[1] - rowrange[0] 71 | collen = self.shape[1] if colrange is None else colrange[1] - colrange[0] 72 | rowoffset = self.scp_index[0] if rowrange is None else self.scp_index[0] + rowrange[0] 73 | coloffset = self.scp_index[1] if colrange is None else self.scp_index[1] + colrange[0] 74 | 75 | irow = np.tile(np.arange(rowlen), (collen, 1)).T 76 | irow -= rowoffset 77 | xrow = irow * self.spacing[0] 78 | 79 | icol = np.tile(np.arange(collen), (rowlen, 1)) 80 | icol -= coloffset 81 | ycol = icol * self.spacing[1] 82 | return xrow, ycol 83 | 84 | def load_scaled_data( 85 | self, scale: str, power: bool = False, rowrange: Optional[tuple] = None, colrange: Optional[tuple] = None 86 | ) -> np.ndarray: 87 | """Load scaled data from the SICD file. 88 | 89 | Args: 90 | scale: Scale type, either 'beta0' or 'sigma0'. 91 | power: If True, return power (squared magnitude), otherwise return complex data. 92 | rowrange: Optional tuple specifying the range of rows (start, end). 93 | colrange: Optional tuple specifying the range of columns (start, end). 94 | 95 | Returns: 96 | 2D numpy array of scaled data, either power or complex, based on the scale type. 97 | """ 98 | if scale == 'beta0': 99 | coeff = self.beta0.Coefs 100 | elif scale == 'sigma0': 101 | coeff = self.sigma0.Coefs 102 | else: 103 | raise ValueError(f'Scale must be either "beta0" or "sigma0", got {scale}') 104 | 105 | xrow, ycol = self.get_xrow_ycol(rowrange=rowrange, colrange=colrange) 106 | if colrange is not None and rowrange is not None: 107 | data = self.reader[rowrange[0] : rowrange[1], colrange[0] : colrange[1]] 108 | elif colrange is None and rowrange is None: 109 | data = self.reader[:, :] 110 | else: 111 | raise ValueError('Both xrange and yrange must be provided or neither.') 112 | 113 | scale_factor = polyval2d(xrow, ycol, coeff) 114 | del xrow, ycol # deleting for memory management 115 | 116 | if power: 117 | data = (data.real**2 + data.imag**2) * scale_factor 118 | else: 119 | data = data * np.sqrt(scale_factor) 120 | return data 121 | 122 | def create_complex_beta0(self, outpath: str, row_iter: int = 256) -> None: 123 | """Create a complex beta0 image from the SICD data. 124 | Calculates the beta0 data in chunks to avoid memory issues. 125 | 126 | Args: 127 | outpath: Path to save the output beta0 TIFF file. 128 | row_iter: Number of rows to process in each chunk. 129 | """ 130 | driver = gdal.GetDriverByName('GTiff') 131 | # Shape transposed for ISCE3 expectations 132 | ds = driver.Create(str(outpath), self.shape[0], self.shape[1], 1, gdal.GDT_CFloat32) 133 | band = ds.GetRasterBand(1) 134 | n_chunks = int(np.floor(self.shape[0] // row_iter)) + 1 135 | for i in range(n_chunks): 136 | start_row = i * row_iter 137 | end_row = min((i + 1) * row_iter, self.shape[0]) 138 | rowrange = [start_row, end_row] 139 | colrange = [0, self.shape[1]] 140 | scaled_data = self.load_scaled_data('beta0', power=False, rowrange=rowrange, colrange=colrange) 141 | # Shape transposed for ISCE3 expectations 142 | if self.az_reversed: 143 | scaled_data = scaled_data[:, ::-1].T 144 | else: 145 | scaled_data = scaled_data.T 146 | # Offset transposed to match ISCE3 expectations 147 | band.WriteArray(scaled_data, xoff=start_row, yoff=0) 148 | 149 | band.FlushCache() 150 | ds.FlushCache() 151 | ds = None 152 | 153 | 154 | class SicdRzdSlc(Slc, SicdSlc): 155 | """Class for SICD SLCs with range zero doppler grids.""" 156 | 157 | def __init__(self, sicd_path: Path): 158 | super().__init__(sicd_path) 159 | assert self.source.Grid.Type == 'RGZERO', 'Only range zero doppler grids are supported for by this class' 160 | first_col_time = self.source.RMA.INCA.TimeCAPoly(-self.shift[1] * self.spacing[1]) 161 | last_col_time = self.source.RMA.INCA.TimeCAPoly((self.shape[1] - self.shift[1]) * self.spacing[1]) 162 | self.az_reversed = last_col_time < first_col_time 163 | self.sensing_start = min(first_col_time, last_col_time) 164 | self.sensing_end = max(first_col_time, last_col_time) 165 | self.starting_range = self.get_starting_range(0) 166 | self.az_reversed = last_col_time < first_col_time 167 | self.prf = self.shape[1] / (self.sensing_end - self.sensing_start) 168 | self.orbit = self.get_orbit() 169 | self.radar_grid = self.get_radar_grid() 170 | self.doppler_centroid_grid = isce3.core.LUT2d() 171 | self.supports_rtc = True 172 | 173 | def get_starting_range(self, col: int) -> float: 174 | assert 0 <= col < self.shape[1], 'Row index out of bounds' 175 | ycol = (col - self.shift[1]) * self.spacing[1] 176 | xrow = -self.shift[0] * self.spacing[0] # fixing to first row 177 | inca_time = self.source.RMA.INCA.TimeCAPoly(ycol) 178 | arp_pos = self.arp_pos_poly(inca_time) 179 | row_offset = self.source.Grid.Row.UVectECF.get_array() * xrow 180 | col_offset = self.source.Grid.Col.UVectECF.get_array() * ycol 181 | grid_pos = self.source.GeoData.SCP.ECF.get_array() + row_offset + col_offset 182 | starting_range = np.linalg.norm(arp_pos - grid_pos) 183 | return starting_range 184 | 185 | def get_orbit(self) -> isce3.core.Orbit: 186 | """Define the orbit for the SLC. 187 | 188 | Returns: 189 | An instance of isce3.core.Orbit representing the orbit. 190 | """ 191 | svs = [] 192 | orbit_start = np.floor(self.sensing_start) - 10 193 | orbit_end = np.ceil(self.sensing_end) + 10 194 | for offset_sec in np.arange(orbit_start, orbit_end + 1, 1): 195 | t = self.sensing_start + offset_sec 196 | pos = self.arp_pos_poly(t) 197 | vel = self.arp_pos_poly.derivative_eval(t) 198 | t_isce = to_isce_datetime(self.reference_time + timedelta(seconds=t)) 199 | svs.append(isce3.core.StateVector(t_isce, pos, vel)) 200 | return isce3.core.Orbit(svs, to_isce_datetime(self.reference_time)) 201 | 202 | def get_radar_grid(self) -> isce3.product.RadarGridParameters: 203 | """Define the radar grid parameters for the SLC. 204 | 205 | Returns: 206 | An instance of isce3.product.RadarGridParameters representing the radar grid. 207 | """ 208 | radar_grid = isce3.product.RadarGridParameters( 209 | sensing_start=self.sensing_start, 210 | wavelength=self.wavelength, 211 | prf=self.prf, 212 | starting_range=self.starting_range, 213 | range_pixel_spacing=self.range_pixel_spacing, 214 | lookside=isce3.core.LookSide.Right if self.lookside == 'right' else isce3.core.LookSide.Left, 215 | length=self.shape[1], # flipped for "shadows down" convention 216 | width=self.shape[0], # flipped for "shadows down" convention 217 | ref_epoch=to_isce_datetime(self.reference_time), 218 | ) 219 | return radar_grid 220 | 221 | def create_geogrid(self, spacing_meters: int) -> isce3.product.GeoGridParameters: 222 | return define_geogrid.generate_geogrids(self, spacing_meters, self.local_epsg) 223 | 224 | def _print_wkt(self): 225 | return print_wkt(self) 226 | 227 | 228 | class SicdPfaSlc(Slc, SicdSlc): 229 | """Class for SICD SLCs with PFA (Polar Format Algorithm) grids.""" 230 | 231 | def __init__(self, sicd_path: Path): 232 | super().__init__(sicd_path) 233 | assert self.source.ImageFormation.ImageFormAlgo == 'PFA', 'Only PFA-focused data are supported by this class' 234 | assert self.source.Grid.Type == 'RGAZIM', 'Only range azimuth grids are supported by this class' 235 | assert self.raw_time_coa_poly.Coefs.size == 1, 'Only constant COA time is currently supported' 236 | self.coa_time = self.raw_time_coa_poly.Coefs[0][0] 237 | self.arp_vel = self.source.SCPCOA.ARPVel.get_array() 238 | self.scp_time = self.reference_time + timedelta(self.source.SCPCOA.SCPTime) 239 | self.sensing_start = self.coa_time 240 | self.pfa_vars = self.source.PFA 241 | self.orbit = self.get_orbit() 242 | self.rrdot_offset = self.calculate_range_range_rate_offset() 243 | self.transform_matrix = self.calculate_transform_matrix() 244 | self.transform_matrix_inv = np.linalg.inv(self.transform_matrix) 245 | # TOOD: this may not always be true, will need to figure out a way to check 246 | self.az_reversed = False 247 | # Without ISCE3 support for PFA grids, these properties are undefined 248 | self.starting_range = np.nan 249 | self.radar_grid = None 250 | self.doppler_centroid_grid = None 251 | self.prf = np.nan 252 | self.az_reversed = False 253 | self.supports_rtc = False 254 | 255 | def get_orbit(self) -> isce3.core.Orbit: 256 | """Define the orbit for the SLC. 257 | PFA data has a constant COA time, so we create a simple orbit 258 | 259 | Returns: 260 | An instance of isce3.core.Orbit representing the orbit. 261 | """ 262 | svs = [] 263 | sensing_start_isce = to_isce_datetime(self.scp_time) 264 | for offset_sec in range(-10, 10): 265 | t = self.scp_time + timedelta(offset_sec) 266 | t_isce = to_isce_datetime(t) 267 | pos = self.arp_vel * offset_sec + self.arp_pos 268 | svs.append(isce3.core.StateVector(t_isce, pos, self.arp_vel)) 269 | return isce3.core.Orbit(svs, sensing_start_isce) 270 | 271 | def calculate_range_range_rate_offset(self) -> np.ndarray: 272 | """Calculate the range and range rate offset for PFA data. 273 | 274 | Returns: 275 | A 2D numpy array containing the range and range rate offsets. 276 | """ 277 | arp_minus_scp = self.arp_pos - self.scp_pos 278 | range_scp_to_coa = np.linalg.norm(arp_minus_scp, axis=-1) 279 | range_rate_scp_to_coa = np.sum(self.arp_vel * arp_minus_scp, axis=-1) / range_scp_to_coa 280 | rrdot_offset = np.array([range_scp_to_coa, range_rate_scp_to_coa]) 281 | return rrdot_offset 282 | 283 | def calculate_transform_matrix(self) -> np.ndarray: 284 | """Define the matrix for transforming PFA grid coordinates to range and range rate. 285 | 286 | Returns: 287 | A 2x2 numpy array representing the transformation matrix. 288 | """ 289 | polar_ang_poly = self.pfa_vars.PolarAngPoly 290 | spatial_freq_sf_poly = self.pfa_vars.SpatialFreqSFPoly 291 | polar_ang_poly_der = polar_ang_poly.derivative(der_order=1, return_poly=True) 292 | spatial_freq_sf_poly_der = spatial_freq_sf_poly.derivative(der_order=1, return_poly=True) 293 | 294 | polar_ang_poly_der = polar_ang_poly.derivative(der_order=1, return_poly=True) 295 | spatial_freq_sf_poly_der = spatial_freq_sf_poly.derivative(der_order=1, return_poly=True) 296 | 297 | thetaTgtCoa = polar_ang_poly(self.coa_time) 298 | dThetaDtTgtCoa = polar_ang_poly_der(self.coa_time) 299 | 300 | # Compute polar aperture scale factor (KSF) and derivative 301 | # wrt polar angle 302 | ksfTgtCoa = spatial_freq_sf_poly(thetaTgtCoa) 303 | dKsfDThetaTgtCoa = spatial_freq_sf_poly_der(thetaTgtCoa) 304 | 305 | # Compute spatial frequency domain phase slopes in Ka and Kc directions 306 | # NB: sign for the phase may be ignored as it is cancelled 307 | # in a subsequent computation. 308 | dPhiDKaTgtCoa = np.array([np.cos(thetaTgtCoa), np.sin(thetaTgtCoa)]) 309 | dPhiDKcTgtCoa = np.array([-np.sin(thetaTgtCoa), np.cos(thetaTgtCoa)]) 310 | 311 | transform_matrix = np.zeros((2, 2)) 312 | transform_matrix[0, :] = ksfTgtCoa * dPhiDKaTgtCoa 313 | transform_matrix[1, :] = dThetaDtTgtCoa * (dKsfDThetaTgtCoa * dPhiDKaTgtCoa + ksfTgtCoa * dPhiDKcTgtCoa) 314 | return transform_matrix 315 | 316 | def rowcol2geo(self, rc: np.ndarray, hae: float) -> np.ndarray: 317 | """Transform grid (row, col) coordinates to ECEF coordinates. 318 | 319 | Args: 320 | rc: 2D array of (row, col) coordinates 321 | hae: Height above ellipsoid (meters) 322 | 323 | Returns: 324 | np.ndarray: ECEF coordinates 325 | """ 326 | dem = isce3.geometry.DEMInterpolator(hae) 327 | elp = isce3.core.Ellipsoid() 328 | rgaz = (rc - np.array(self.shift)[None, :]) * np.array(self.spacing)[None, :] 329 | rrdot = np.dot(self.transform_matrix, rgaz.T) + self.rrdot_offset[:, None] 330 | side = isce3.core.LookSide(1) if self.lookside == 'left' else isce3.core.LookSide(-1) 331 | pts_ecf = [] 332 | wvl = 1.0 333 | for pt in rrdot.T: 334 | r = pt[0] 335 | dop = -pt[1] * 2 / wvl 336 | llh = isce3.geometry.rdr2geo(0.0, r, self.orbit, side, dop, wvl, dem, threshold=1.0e-8, maxiter=50) 337 | pts_ecf.append(elp.lon_lat_to_xyz(llh)) 338 | return np.vstack(pts_ecf) 339 | 340 | def geo2rowcol(self, xyz: np.ndarray) -> np.ndarray: 341 | """Transform ECEF xyz to (row, col). 342 | 343 | Args: 344 | xyz: ECEF coordinates 345 | 346 | Returns: 347 | (row, col) coordinates 348 | """ 349 | rrdot = np.zeros((2, xyz.shape[0])) 350 | rrdot[0, :] = np.linalg.norm(xyz - self.arp_pos[None, :], axis=1) 351 | rrdot[1, :] = np.dot(-self.arp_vel, (xyz - self.arp_pos[None, :]).T) / rrdot[0, :] 352 | rgaz = np.dot(self.transform_matrix_inv, (rrdot - self.rrdot_offset[:, None])) 353 | rgaz /= np.array(self.spacing)[:, None] 354 | rgaz += np.array(self.shift)[:, None] 355 | row_col = rgaz.T.copy() 356 | return row_col 357 | 358 | def create_geogrid(self, spacing_meters: int) -> isce3.product.GeoGridParameters: 359 | """Create a geogrid for the PFA SLC. 360 | Note: Unlike other Slc subclasses, the PFA geogrid is always defined in EPSG 4326 (Lat/Lon). 361 | 362 | Args: 363 | spacing_meters: Spacing in meters for the geogrid. 364 | 365 | Returns: 366 | isce3.product.GeoGridParameters: The generated geogrid parameters. 367 | """ 368 | ecef = pyproj.CRS(4978) # ECEF on WGS84 Ellipsoid 369 | lla = pyproj.CRS(4979) # WGS84 lat/lon/ellipsoid height 370 | local_utm = pyproj.CRS(define_geogrid.get_point_epsg(self.center.y, self.center.x)) 371 | lla2utm = pyproj.Transformer.from_crs(lla, local_utm, always_xy=True) 372 | utm2lla = pyproj.Transformer.from_crs(local_utm, lla, always_xy=True) 373 | ecef2lla = pyproj.Transformer.from_crs(ecef, lla, always_xy=True) 374 | 375 | lla_point = (self.center.x, self.center.y) 376 | utm_point = lla2utm.transform(*lla_point) 377 | utm_point_shift = (utm_point[0] + spacing_meters, utm_point[1]) 378 | lla_point_shift = utm2lla.transform(*utm_point_shift) 379 | x_spacing = lla_point_shift[0] - lla_point[0] 380 | y_spacing = -1 * x_spacing 381 | 382 | points = np.array([(0, 0), (0, self.shape[1]), self.shape, (self.shape[0], 0)]) 383 | geos = self.rowcol2geo(points, self.scp_hae) 384 | 385 | points = np.vstack(ecef2lla.transform(geos[:, 0], geos[:, 1], geos[:, 2])).T 386 | minx, maxx = np.min(points[:, 0]), np.max(points[:, 0]) 387 | miny, maxy = np.min(points[:, 1]), np.max(points[:, 1]) 388 | 389 | width = (maxx - minx) // x_spacing 390 | length = (maxy - miny) // np.abs(y_spacing) 391 | geogrid = isce3.product.GeoGridParameters( 392 | start_x=float(minx), 393 | start_y=float(maxy), 394 | spacing_x=float(x_spacing), 395 | spacing_y=float(y_spacing), 396 | length=int(length), 397 | width=int(width), 398 | epsg=4326, 399 | ) 400 | geogrid_snapped = define_geogrid.snap_geogrid(geogrid, geogrid.spacing_x, geogrid.spacing_y) 401 | return geogrid_snapped 402 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forrestfwilliams/MultiRTC/55d26803c43c1424c262cb24722ea3aba3aa5907/tests/conftest.py -------------------------------------------------------------------------------- /tests/test_dem.py: -------------------------------------------------------------------------------- 1 | from shapely.geometry import box 2 | 3 | from multirtc import dem 4 | 5 | 6 | def test_get_granule_url(): 7 | test_url = 'https://nisar.asf.earthdatacloud.nasa.gov/STATIC/DEM/v1.1/EPSG4326/S10_W020/DEM_S01_00_W001_00.tif' 8 | url = dem.get_dem_granule_url(-1, -1) 9 | assert url == test_url 10 | 11 | 12 | def test_get_latlon_pairs(): 13 | polygon = box(-1, -1, 1, 1) 14 | latlon_pairs = dem.get_latlon_pairs(polygon) 15 | assert latlon_pairs == [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 0), (0, 1), (1, -1), (1, 0), (1, 1)] 16 | --------------------------------------------------------------------------------