├── .github └── workflows │ ├── main.yaml │ └── publish.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── demo ├── AOI-DigitalSurfaceModel.tif ├── AOI-Mesh.ply ├── AOI-PointCloud.laz └── Foundation-PointCloud.laz ├── docs ├── configuration.md ├── details.md ├── example.md └── img │ ├── dsm_feature_matches.png │ ├── example_registered.png │ ├── example_unregistered.png │ ├── flowchart.png │ └── reg_mesh.png ├── environment.yml ├── pyproject.toml ├── readme.md ├── src ├── codem │ ├── __init__.py │ ├── __main__.py │ ├── lib │ │ ├── __init__.py │ │ ├── console.py │ │ ├── log.py │ │ ├── progress.py │ │ └── resources.py │ ├── main.py │ ├── preprocessing │ │ ├── __init__.py │ │ └── preprocess.py │ └── registration │ │ ├── __init__.py │ │ ├── apply.py │ │ ├── dsm.py │ │ └── icp.py └── vcd │ ├── __init__.py │ ├── __main__.py │ ├── main.py │ ├── meshing │ ├── __init__.py │ └── mesh.py │ └── preprocessing │ ├── __init__.py │ └── preprocess.py └── tests ├── data ├── aoi_shapefile │ ├── aoi.cpg │ ├── aoi.dbf │ ├── aoi.prj │ ├── aoi.sbn │ ├── aoi.sbx │ ├── aoi.shp │ └── aoi.shx ├── dem.tif ├── mesh.ply ├── pc.laz └── pipeline.json ├── point_cloud.py ├── raster.py ├── test_registration.py └── vcd └── make-laz.sh /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - "dependabot/**" 7 | pull_request: 8 | paths-ignore: 9 | - "**.md" 10 | 11 | env: 12 | PIP_DISABLE_PIP_VERSION_CHECK: 1 13 | 14 | concurrency: 15 | group: ${{ github.head_ref || github.run_id }} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | test: 20 | name: test 21 | runs-on: macos-latest 22 | defaults: 23 | run: 24 | shell: bash -el {0} 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | - uses: conda-incubator/setup-miniconda@v3 29 | with: 30 | miniforge-version: latest 31 | environment-file: environment.yml 32 | auto-update-conda: false 33 | python-version: "3.12" 34 | conda-remove-defaults: true 35 | channels: conda-forge 36 | - name: "Install Test Framework" 37 | run: pip install pytest 38 | - name: "Install Codem" 39 | run: pip install . 40 | - name: "Debug Info" 41 | run: | 42 | echo python location: `which python` 43 | echo python version: `python --version` 44 | echo pytest location: `which pytest` 45 | echo installed packages 46 | conda list 47 | pip list 48 | - name: "Run Tests" 49 | run: pytest tests -v 50 | lint: 51 | name: lint-check 52 | runs-on: ubuntu-latest 53 | steps: 54 | - name: Checkout repository 55 | uses: actions/checkout@v4 56 | with: 57 | # We must fetch at least the immediate parents so that if this is 58 | # a pull request then we can checkout the head. 59 | fetch-depth: 2 60 | - name: Setup Python 61 | uses: actions/setup-python@v5 62 | with: 63 | # Semantic version range syntax or exact version of a Python version 64 | python-version: "3.12" 65 | - name: Install Linting Tools 66 | run: | 67 | python -m pip install mypy numpy types-PyYAML typing-extensions 68 | - name: Run mypy 69 | run: mypy src 70 | analyze: 71 | name: analyze 72 | runs-on: ubuntu-latest 73 | steps: 74 | - name: Checkout repository 75 | uses: actions/checkout@v4 76 | with: 77 | # We must fetch at least the immediate parents so that if this is 78 | # a pull request then we can checkout the head. 79 | fetch-depth: 2 80 | - uses: conda-incubator/setup-miniconda@v3 81 | with: 82 | miniforge-version: latest 83 | environment-file: environment.yml 84 | auto-update-conda: false 85 | python-version: "3.12" 86 | conda-remove-defaults: true 87 | channels: conda-forge 88 | # Initializes the CodeQL tools for scanning. 89 | - name: Initialize CodeQL 90 | uses: github/codeql-action/init@v3 91 | with: 92 | languages: "python" 93 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 94 | # If you wish to specify custom queries, you can do so here or in a config file. 95 | # By default, queries listed here will override any specified in a config file. 96 | # Prefix the list here with "+" to use these queries and those in the config file. 97 | queries: +security-and-quality 98 | 99 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 100 | # If this step fails, then you should remove it and run the build manually (see below) 101 | - name: Autobuild 102 | uses: github/codeql-action/autobuild@v3 103 | 104 | # ℹ️ Command-line programs to run using the OS shell. 105 | # 📚 https://git.io/JvXDl 106 | 107 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 108 | # and modify them (or add more) to build your code if your project 109 | # uses a compiled language 110 | 111 | #- run: | 112 | # make bootstrap 113 | # make release 114 | 115 | - name: Perform CodeQL Analysis 116 | uses: github/codeql-action/analyze@v3 117 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using setup.py/twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Python 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install build 24 | pip install build twine setuptools 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: __token__ 28 | # The PYPI_PASSWORD must be a pypi token with the "pypi-" prefix with sufficient permissions to upload this package 29 | # https://pypi.org/help/#apitoken 30 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 31 | run: | 32 | python3 -m build 33 | twine upload dist/* 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | __pycache__/ 3 | *.egg-info/ 4 | build/ 5 | dist/ 6 | demo/CODEM_* 7 | demo/registration_* 8 | docker/ 9 | 10 | arcgis/* 11 | !arcgis/*.pyt 12 | !arcgis/*.tbx 13 | !arcgis/*.pyt.xml 14 | !arcgis/*.yml 15 | !arcgis/*pdf 16 | 17 | .DS_Store 18 | 19 | tests/data/temporary 20 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 23.3.0 4 | hooks: 5 | - id: black 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.4.0 8 | hooks: 9 | - id: check-yaml 10 | - id: check-toml 11 | - id: check-xml 12 | - id: check-merge-conflict 13 | - id: check-case-conflict 14 | - id: check-ast 15 | - id: end-of-file-fixer 16 | - id: trailing-whitespace 17 | - id: debug-statements 18 | - id: mixed-line-ending 19 | - repo: https://github.com/asottile/reorder_python_imports 20 | rev: v3.9.0 21 | hooks: 22 | - id: reorder-python-imports 23 | - repo: https://github.com/pre-commit/mirrors-mypy 24 | rev: v1.1.1 25 | hooks: 26 | - id: mypy 27 | language_version: python3.9 28 | additional_dependencies: [types-PyYAML] 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /demo/AOI-DigitalSurfaceModel.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NCALM-UH/CODEM/2264bc062ad827ca0f4558b3d0fb5ad40a90dcb3/demo/AOI-DigitalSurfaceModel.tif -------------------------------------------------------------------------------- /demo/AOI-Mesh.ply: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NCALM-UH/CODEM/2264bc062ad827ca0f4558b3d0fb5ad40a90dcb3/demo/AOI-Mesh.ply -------------------------------------------------------------------------------- /demo/AOI-PointCloud.laz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NCALM-UH/CODEM/2264bc062ad827ca0f4558b3d0fb5ad40a90dcb3/demo/AOI-PointCloud.laz -------------------------------------------------------------------------------- /demo/Foundation-PointCloud.laz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NCALM-UH/CODEM/2264bc062ad827ca0f4558b3d0fb5ad40a90dcb3/demo/Foundation-PointCloud.laz -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuring CODEM 2 | 3 | ## Overview 4 | 5 | Numerous algorithm parameters can be tuned when running `CODEM` by specifying option names and values. The available options can be viewed by running `codem --help`: 6 | 7 | ```bash 8 | $ codem --help 9 | usage: codem [-h] [--min_resolution MIN_RESOLUTION] [--dsm_akaze_threshold DSM_AKAZE_THRESHOLD] 10 | [--dsm_lowes_ratio DSM_LOWES_RATIO] [--dsm_ransac_max_iter DSM_RANSAC_MAX_ITER] 11 | [--dsm_ransac_threshold DSM_RANSAC_THRESHOLD] [--dsm_solve_scale DSM_SOLVE_SCALE] 12 | [--dsm_strong_filter DSM_STRONG_FILTER] [--dsm_weak_filter DSM_WEAK_FILTER] 13 | [--icp_angle_threshold ICP_ANGLE_THRESHOLD] [--icp_distance_threshold ICP_DISTANCE_THRESHOLD] 14 | [--icp_max_iter ICP_MAX_ITER] [--icp_rmse_threshold ICP_RMSE_THRESHOLD] 15 | [--icp_robust ICP_ROBUST] [--icp_solve_scale ICP_SOLVE_SCALE] [--verbose VERBOSE] 16 | foundation_file aoi_file 17 | ``` 18 | 19 | In most cases, the default parameter values (see below) are sufficient. 20 | 21 | ## Configuration Parameters 22 | 23 | Option values must adhere to the following limits and data types: 24 | 25 | **Coarse, Feature-Based Registration Parameters:** 26 | 27 | * `DSM_STRONG_FILTER` 28 | * description: standard deviation of the large Gaussian filter used to normalize the DSM prior to feature extraction; larger values allow longer wavelength vertical features to pass through into the normalized DSM 29 | * command line argument: `-dst` or `--dsm_strong_filter` 30 | * units: meters 31 | * dtype: `float` 32 | * limits: `x > 0.0` 33 | * default: `10` 34 | * `DSM_WEAK_FILTER` 35 | * description: standard deviation of the small Gaussian filter used to normalize the DSM prior to feature extraction; larger values increasingly blur short wavelength vertical features in the normalized DSM 36 | * command line argument: `dwf` or `--dsm_weak_filter` 37 | * units: meters 38 | * dtype: `float` 39 | * limits: `x > 0.0` 40 | * default: `1` 41 | * `DSM_AKAZE_THRESHOLD` 42 | * description: [Accelerated-KAZE](http://www.bmva.org/bmvc/2013/Papers/paper0013/paper0013.pdf) feature detection response threshold; larger values require increasingly distinctive local geometry for a feature to be detected 43 | * command line argument: `-dat` or `--dsm_akaze_threshold` 44 | * units: none 45 | * dtype: `float` 46 | * limits: `x > 0.0` 47 | * default: `0.0001` 48 | * `DSM_LOWES_RATIO` 49 | * description: feature matching relative strength control; larger values allow weaker matches relative to the next best match 50 | * command line argument: `-dlr` or `--dsm_lowes_ratio` 51 | * units: none 52 | * dtype: `float` 53 | * limits: `0.0 < x < 1.0` 54 | * default: `0.9` 55 | * `DSM_RANSAC_THRESHOLD` 56 | * description: maximum residual error for a matched feature pair to be included in a random sample consensus (RANSAC) solution to a 3D registration transformation; larger values include matched feature pairs with increasingly greater disagreement with the solution 57 | * command line argument: `-drt` or `--dsm_ransac_threshold` 58 | * units: meters 59 | * dtype: `float` 60 | * limits: `x > 0` 61 | * default: `10` 62 | * `DSM_RANSAC_MAX_ITER` 63 | * description: the max iterations for the RANSAC algorithm 64 | * command line argument: `-drmi` or `--dsm_ransac_max_iter` 65 | * units: iterations 66 | * dtype: `int` 67 | * limits: `x > 0` 68 | * default: `10000` 69 | * `DSM_SOLVE_SCALE` 70 | * description: flag to include or exclude scale from the solved coarse registration transformation 71 | * command line argument: `-dss` or `--dsm_solve_scale` 72 | * units: N/A 73 | * dtype: `bool` 74 | * limits: `True` or `False` 75 | * default: `True` 76 | 77 | **Fine, ICP-Based Registration Parameters:** 78 | 79 | * `ICP_MAX_ITER` 80 | * description: the max iterations for the iterative closest point algorithm 81 | * command line argument: `-imi` or `--icp_max_iter` 82 | * units: iterations 83 | * dtype: `int` 84 | * limits: `x > 0` 85 | * default: `100` 86 | * `ICP_RMSE_THRESHOLD` 87 | * description: ICP convergence criterion; minimum relative change between iterations in the root mean squared error 88 | * command line argument: `-irt` or `--icp_rmse_threshold` 89 | * units: meters 90 | * dtype: `float` 91 | * limits: `x > 0` 92 | * default: `0.0001` 93 | * `ICP_ANGLE_THRESHOLD` 94 | * description: ICP convergence criterion; minimum change in Euler angle between iterations 95 | * command line argument: `-iat` or `--icp_angle_threshold` 96 | * units: degrees 97 | * dtype: `float` 98 | * limits: `x > 0` 99 | * default: `0.001` 100 | * `ICP_DISTANCE_THRESHOLD` 101 | * description: ICP convergence criterion; minimum change in translation between iterations 102 | * command line argument: `-idt` or `--icp_distance_threshold` 103 | * units: meters 104 | * dtype: `float` 105 | * limits: `x > 0` 106 | * default: `0.001` 107 | * `ICP_SOLVE_SCALE` 108 | * description: flag to include or exclude scale from the solved fine registration transformation 109 | * command line argument: `-iss` or `--icp_solve_scale` 110 | * units: N/A 111 | * dtype: `bool` 112 | * limits: `True` or `False` 113 | * default: `True` 114 | * `ICP_ROBUST` 115 | * description: flag to include or exclude robust weighting in the fine registration solution 116 | * command line argument: `-ir` or `--icp_robust` 117 | * units: N/A 118 | * dtype: `bool` 119 | * limits: `True` or `False` 120 | * default: `True` 121 | 122 | **Other Parameters:** 123 | 124 | * `MIN_RESOLUTION` 125 | * description: the minimum pipeline data resolution; smaller values increase computation time 126 | * command line argument: `-min` or `--min_resolution` 127 | * units: meters 128 | * dtype: `float` 129 | * limits: `x > 0` 130 | * default: `1.0` 131 | * `VERBOSE` 132 | * description: flag to output verbose logging information to the console 133 | * command line argument: `-v` or `--verbose` 134 | * units: N/A 135 | * dtype: `bool` 136 | * limits: `True` or `False` 137 | * default: `False` 138 | -------------------------------------------------------------------------------- /docs/details.md: -------------------------------------------------------------------------------- 1 | # Detailed Overview 2 | 3 | `CODEM` (Multi-Modal Digital Elevation Model Registration) is a spatial data co-registration tool developed in Python. 4 | 5 | ## Concept 6 | 7 | Conceptually, `CODEM` consists of two back-to-back registration modules: 8 | 9 | 1. An initial coarse global registration based on matched features extracted from digital surface models (DSMs) generated from the AOI and Foundation data sources. 10 | 2. A fine local registration based on an iterative closest point (ICP) algorithm applied to the AOI and Foundation data. 11 | 12 | Each registration module solves a 6- or 7-parameter similarity transformation (three translations, three rotations, one optional scale). The modules are subject to an overall "pipeline resolution" that controls the density of the data flowing through the pipeline. Foundation and AOI data will likely have different densities, and one or both may contain very high data densities, e.g., point clouds with tens to hundreds of points per square meter. In the case of differing data densities, registration accuracy is limited by the lower density data. Thus, the higher density data is resampled to match that of the lower density data for efficiency. A maximum density is also enforced in the case where both the Foundation and AOI data are very high resolution. Very dense data contains redundant information and slows the registration computation time. 13 | 14 | A flowchart illustrating the registration pipeline is given below. 15 | 16 | ![`CODEM` flowchart](./img/flowchart.png) 17 | 18 | ## Pipeline Details 19 | 20 | ### 1. Preprocessing 21 | 22 | #### *Resolution Detection* 23 | 24 | Prior to any data processing, the resolutions (i.e., point spacings) of the Foundation and AOI data are determined and the pipeline resolution set to the larger of the two. However, if this resolution value is still smaller than the threshold specified by the `MIN_RESOLUTION` parameter, then the pipeline resolution is set to the `MIN_RESOLUTION` value. 25 | 26 | The existence of differing distance units (meters, feet, U.S. survey feet) between the Foundation and AOI data files is checked and accommodated in the determined resolution, which is always in meters. If distance units are not specified in a file, the meter unit is assumed. 27 | 28 | #### *DSM Creation* 29 | 30 | The first registration module uses matched features extracted from DSM data. DSMs are therefore created from the Foundation and AOI data with the resolution, i.e., pixel spacing, set to the pipeline resolution. Missing pixels are infilled via inverse distance weighted interpolation. 31 | 32 | #### *DSM Normalization* 33 | 34 | The eventual feature extraction algorithms operate on 8-bit raster data. If the Foundation and/or AOI DSMs contain large elevation changes, e.g., mountainous terrain, features defined by relatively small changes in elevation such as buildings and vegetation will be suppressed when quantized to 8-bits. Therefore, long-wavelength elevation changes, e.g., the slope of a mountain, are removed by applying a bandpass filter to the DSM data prior to the 8-bit conversion. 35 | 36 | The bandpass filter removes elevation changes occurring over large horizontal distances while retaining elevation changes that occur at small horizontal distances. Thus, local feature such as buildings, small slopes, and vegetation texture are able to be preserved when the DSM is quantized to 8-bits. A small amount of smoothing is also applied to elevation changes occurring at very short distances to filter noise. 37 | 38 | #### *Point Cloud Creation* 39 | 40 | The second registration module is an ICP algorithm. Point clouds are generated from the created DSMs (not the normalized DSMs) for the ICP algorithm. Each pixel is simply converted to a 3D point with geospatial coordinates. 41 | 42 | ### 2. Feature-Based Registration 43 | 44 | [AKAZE](http://www.bmva.org/bmvc/2013/Papers/paper0013/paper0013.pdf) (Accelerated-KAZE) features are extracted from the normalized and quantized Foundation and AOI DSMs. [SIFT](https://link.springer.com/article/10.1023/B:VISI.0000029664.99615.94) features were found to perform similarly, but the algorithm was patent-protected until recently. 45 | 46 | Each AOI feature is matched to the most similar Foundation feature according to nearness in feature space. The matched features are initially filtered using the standard [Lowe's ratio test](https://link.springer.com/article/10.1023/B:VISI.0000029664.99615.94). RANSAC is then applied to the filtered matches to determine the matched feature locations that best define a 6- or 7-parameter transformation from the AOI to Foundation coordinate system. 47 | 48 | Feature matching is blind to spatial location. Thus, the feature-based registration does not require the Foundation and AOI data to be in the same coordinate system. However, a few meters or more of error typically remain after a feature-based registration. 49 | 50 | ### 3. ICP-Based Registration 51 | 52 | The purpose of the ICP registration is to remove much of the error remaining in the feature-based registration result. A point to plane ICP algorithm is used with hard and soft outlier thresholds applied to the closest point matches to reduce the effect of systematic error and temporal change in the Foundation and AOI data. The hard threshold value is set from the root mean square error of the feature-based registration. A soft threshold is enforced by employing iteratively re-weighted least squares for the ICP solution. 53 | 54 | ICP is a local registration solution and requires an initial guess to get started. The transformation solved in the feature-based registration serves this purpose. The ICP algorithm uses the Foundation and AOI point clouds created in the Preprocessing step and iterates until a minimum change in motion (distance or angle) or mean square error between the matched points is achieved. 55 | 56 | The final registration transformation is the product of the ICP fine registration transformation matrix and the feature-based registration transformation matrix. 57 | 58 | ### 4. Registration Application to AOI Data 59 | 60 | The final registration transformation is applied to the original AOI data file to transform the AOI data into the Foundation data coordinate system and the updated AOI data is saved to a new file with the term "`_registered`" appended to the file name. Note that the distance unit and coordinate reference system of the new AOI data file will match that of the Foundation data. 61 | 62 | ## Inputs & Outputs 63 | 64 | `CODEM` was designed to be agnostic to data types and file formats. Thus, `CODEM` accepts point cloud, mesh, and DSM data types. File formats are currently limited to LAS, LAZ, and BPF for point clouds, PLY and OBJ for mesh data, and GeoTIFF images for DSMs. Additionally file formats can be added if necessary, as I/O for each data type is handled by generic libraries: [PDAL](https://pdal.io/) for point clouds, [trimesh](https://trimsh.org/index.html) for mesh data, and [GDAL](https://gdal.org/) for DSM raster products. 65 | 66 | All output is saved to a new directory that is created at the location of the AOI file. The directory name is tagged with the date and time of execution: `registration_YYYY-MM-DD_HH-MM-SS`. The directory contents include the following: 67 | 68 | 1. Registered AOI Data File: The registered AOI file will be of the same data type and file format as the original AOI file and will have the same name with term "`_registered`" appended to end of the name. 69 | 2. `config.yml`: A record of the parameters used in the registration. 70 | 3. `log.txt`: A log file that may be useful for debugging or insight into reasons for a failed registration. 71 | 4. `registration.txt`: Contains the solved coarse and fine registration transformation parameters and a few statistics. 72 | 5. `dsm_feature_matches.png`: An image of the matched features used in the coarse registration step. 73 | 74 | **Example feature match visualization:** 75 | 76 | ![Feature Matches](./img/dsm_feature_matches.png) 77 | 78 | **Example registration parameter output:** 79 | 80 | ```console 81 | DSM FEATURE REGISTRATION 82 | ------------------------ 83 | Transformation matrix: 84 | [[ 9.99234831e-01 -2.11892556e-04 -1.74235703e-04 1.39586681e+03] 85 | [ 2.12011646e-04 9.99234613e-01 6.83244873e-04 3.17553089e+03] 86 | [ 1.74090772e-04 -6.83281816e-04 9.99234620e-01 2.84471944e+03] 87 | [ 0.00000000e+00 0.00000000e+00 0.00000000e+00 1.00000000e+00]] 88 | Transformation Parameters: 89 | Omega = -0.039 degrees 90 | Phi = -0.010 degrees 91 | Kappa = 0.012 degrees 92 | X Translation = 1395.867 93 | Y Translation = 3175.531 94 | Z Translation = 2844.719 95 | Scale = 0.999235 96 | Number of pairs = 373 97 | RMSEs: 98 | X = +/-1.102, 99 | Y = +/-1.308, 100 | Z = +/-0.804, 101 | 3D = +/-1.890 102 | 103 | ICP REGISTRATION 104 | ---------------- 105 | Transformation matrix: 106 | [[ 9.99578165e-01 -3.87857454e-04 -9.49183210e-05 1.94055589e+03] 107 | [ 3.87859287e-04 9.99578169e-01 1.92820610e-05 1.58154769e+03] 108 | [ 9.49108320e-05 -1.93188899e-05 9.99578240e-01 2.40643763e+01] 109 | [ 0.00000000e+00 0.00000000e+00 0.00000000e+00 1.00000000e+00]] 110 | Transformation Parameters: 111 | Omega = -0.001 degrees 112 | Phi = -0.005 degrees 113 | Kappa = 0.022 degrees 114 | X Translation = 1940.556 115 | Y Translation = 1581.548 116 | Z Translation = 24.064 117 | Scale = 0.999578 118 | Number of pairs = 58183 119 | RMSEs: 120 | X = +/-0.444, 121 | Y = +/-0.256, 122 | Z = +/-0.315, 123 | 3D = +/-0.601 124 | 125 | ``` 126 | 127 | ## Assumptions and Limitations 128 | 129 | * `CODEM` assumes the Foundation and AOI data has been preprocessed to remove stray data, e.g., in-air points from atmospheric returns in lidar point clouds. Additional preprocessing filters can be added to `CODEM` if necessary. 130 | * DSM data (GeoTIFF format) must not contain a rotation or differing scales in the X and Y directions (these are defined by the transform in the GeoTIFF file). `CODEM` will abort the registration process if either of these conditions is encountered. 131 | * Very small areas (e.g., 100 x 100 meters) or very coarse resolution data (e.g., a DSM with 10 meter pixels) may contain insufficient information for `CODEM` to solve the registration. 132 | * Data file formats are currently limited to the following (more can be added if necessary): 133 | * Point Clouds: LAS, LAZ, and BPF 134 | * DSMs: GeoTIFF 135 | * Mesh: PLY and OBJ 136 | * `CODEM` cannot handle large (> 50%) differences in scale between Foundation and AOI data. 137 | * If data a lacks linear unit type, meters will be assumed. 138 | -------------------------------------------------------------------------------- /docs/example.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | ## 1. Unregistered AOI 4 | 5 | We will register a PLY format mesh AOI to a LAZ format point cloud Foundation data source. The AOI mesh data (color) is rotated and displaced with respect to the Foundation point cloud (grayscale). The data files are found in the `demo` directory of this repository. 6 | 7 | * Foundation file = `demo/Foundation-PointCloud.laz` 8 | * AOI file = `demo/AOI-Mesh.ply` 9 | 10 | ![Example Unregistered](./img/example_unregistered.png) 11 | 12 | ## 2. Running CODEM 13 | 14 | We will override the default setting for the pipeline minimum resolution by using the `--min_resolution` option when running `CODEM`: 15 | 16 | ```bash 17 | $ codem demo/Foundation-PointCloud.laz demo/AOI-Mesh.ply --min_resolution 2.0 18 | ╔════════════════════════════════════╗ 19 | ║ CODEM ║ 20 | ╚════════════════════════════════════╝ 21 | ║ AUTHORS: Preston Hartzell & ║ 22 | ║ Jesse Shanahan ║ 23 | ║ DEVELOPED FOR: CRREL/NEGGS ║ 24 | ╚════════════════════════════════════╝ 25 | 26 | ══════════════PARAMETERS══════════════ 27 | 11:11:49 - INFO - FND_FILE = demo/Foundation-PointCloud.laz 28 | 11:11:49 - INFO - AOI_FILE = demo/AOI-Mesh.ply 29 | 11:11:49 - INFO - MIN_RESOLUTION = 2.0 30 | 11:11:49 - INFO - DSM_AKAZE_THRESHOLD = 0.0001 31 | 11:11:49 - INFO - DSM_LOWES_RATIO = 0.9 32 | 11:11:49 - INFO - DSM_RANSAC_MAX_ITER = 10000 33 | 11:11:49 - INFO - DSM_RANSAC_THRESHOLD = 10.0 34 | 11:11:49 - INFO - DSM_SOLVE_SCALE = True 35 | 11:11:49 - INFO - DSM_STRONG_FILTER_SIZE = 10.0 36 | 11:11:49 - INFO - DSM_WEAK_FILTER_SIZE = 1.0 37 | 11:11:49 - INFO - ICP_ANGLE_THRESHOLD = 0.001 38 | 11:11:49 - INFO - ICP_DISTANCE_THRESHOLD = 0.001 39 | 11:11:49 - INFO - ICP_MAX_ITER = 100 40 | 11:11:49 - INFO - ICP_RMSE_THRESHOLD = 0.0001 41 | 11:11:49 - INFO - ICP_ROBUST = True 42 | 11:11:49 - INFO - ICP_SOLVE_SCALE = True 43 | 11:11:49 - INFO - VERBOSE = False 44 | 11:11:49 - INFO - ICP_SAVE_RESIDUALS = False 45 | 11:11:49 - INFO - OUTPUT_DIR = /home/pjhartze/dev/codem/demo/registration_2021-08-05_11-11-49 46 | ══════════PREPROCESSING DATA══════════ 47 | 11:11:53 - INFO - Linear unit for Foundation-PCLOUD detected as metre. 48 | 11:11:53 - INFO - Calculated native resolution for Foundation-PCLOUD as: 0.6 meters 49 | 11:11:53 - WARNING - Linear unit for AOI-MESH not detected --> meters assumed 50 | 11:11:53 - INFO - Calculated native resolution for AOI-MESH as: 0.6 meters 51 | 11:11:53 - INFO - Preparing Foundation-PCLOUD for registration. 52 | 11:11:53 - INFO - Extracting DSM from Foundation-PCLOUD with resolution of: 2.0 meters 53 | 11:12:01 - INFO - Preparing AOI-MESH for registration. 54 | 11:12:01 - INFO - Extracting DSM from AOI-MESH with resolution of: 2.0 meters 55 | 11:12:02 - INFO - Registration resolution has been set to: 2.0 meters 56 | ═════BEGINNING COARSE REGISTRATION═════ 57 | 11:12:02 - INFO - Solving DSM feature registration. 58 | 11:12:05 - INFO - Saving DSM feature match visualization to: /home/pjhartze/dev/codem/demo/registration_2021-08-05_11-11-49/dsm_feature_matches.png 59 | 11:12:05 - INFO - Saving DSM feature registration parameters to: /home/pjhartze/dev/codem/demo/registration_2021-08-05_11-11-49/registration.txt 60 | ══════BEGINNING FINE REGISTRATION══════ 61 | 11:12:05 - INFO - Solving ICP registration. 62 | 11:12:05 - INFO - Saving ICP registration parameters to: /home/pjhartze/dev/codem/demo/registration_2021-08-05_11-11-49/registration.txt 63 | ═════════APPLYING REGISTRATION═════════ 64 | 11:12:06 - INFO - Registration has been applied to AOI-MESH and saved to: /home/pjhartze/dev/codem/demo/registration_2021-08-05_11-11-49/AOI-Mesh_registered.ply 65 | 66 | CODEM Stage: Performing Fine Registration 00:16 67 | Registration Process 100%|█████████████████████████████████████████████████████████| 100/100 [00:17<00:00, 5.84/s] 68 | $ 69 | ``` 70 | 71 | Upon completion, all output, including a registered version of the AOI data file named `AOI-Mesh_registered.ply`, is saved to a new directory at the AOI file location. For this example, the new directory name is `registration_2021-08-05_11-11-49`. 72 | 73 | ## 3. Registered AOI 74 | 75 | The registered AOI mesh overlaid on the Foundation point cloud is shown below. 76 | 77 | ![Example Registered](./img/example_registered.png) 78 | 79 | ## 4. Additional Examples 80 | 81 | An unregistered point cloud AOI file and an unregistered DSM AOI file are also supplied in the `demo` directory for further experimentation/demonstration with different data types. 82 | -------------------------------------------------------------------------------- /docs/img/dsm_feature_matches.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NCALM-UH/CODEM/2264bc062ad827ca0f4558b3d0fb5ad40a90dcb3/docs/img/dsm_feature_matches.png -------------------------------------------------------------------------------- /docs/img/example_registered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NCALM-UH/CODEM/2264bc062ad827ca0f4558b3d0fb5ad40a90dcb3/docs/img/example_registered.png -------------------------------------------------------------------------------- /docs/img/example_unregistered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NCALM-UH/CODEM/2264bc062ad827ca0f4558b3d0fb5ad40a90dcb3/docs/img/example_unregistered.png -------------------------------------------------------------------------------- /docs/img/flowchart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NCALM-UH/CODEM/2264bc062ad827ca0f4558b3d0fb5ad40a90dcb3/docs/img/flowchart.png -------------------------------------------------------------------------------- /docs/img/reg_mesh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NCALM-UH/CODEM/2264bc062ad827ca0f4558b3d0fb5ad40a90dcb3/docs/img/reg_mesh.png -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: codem 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python-pdal 6 | - opencv 7 | - rasterio 8 | - trimesh 9 | - matplotlib 10 | - scikit-image 11 | - rich 12 | - numpy 13 | - typing-extensions 14 | - gdal 15 | - pyproj 16 | - pyshp 17 | - pandas 18 | - pyyaml 19 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "codem" 3 | requires-python = ">=3.9" 4 | description = "A package for co-registering geospatial data" 5 | readme = "readme.md" 6 | license = { text = "Apache-2.0" } 7 | authors = [ 8 | { name = "Preston Hartzell", email = "pjhartzell@uh.edu" }, 9 | { name = "Jesse Shanahan" }, 10 | { name = "Bahirah Adewunmi" }, 11 | ] 12 | maintainers = [{ name = "Ognyan Moore", email = "ogi@hobu.co" }] 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "Intended Audience :: Information Technology", 16 | "Intended Audience :: Science/Research", 17 | "License :: OSI Approved :: Apache Software License", 18 | "Topic :: Scientific/Engineering :: GIS", 19 | "Topic :: Scientific/Engineering :: Image Processing", 20 | ] 21 | dependencies = ["typing-extensions"] 22 | dynamic = ["version"] 23 | 24 | [project.urls] 25 | homepage = "https://github.com/NCALM-UH/CODEM" 26 | repository = "https://github.com/NCALM-UH/CODEM" 27 | 28 | [tool.setuptools] 29 | package-dir = { "" = "src" } 30 | zip-safe = false 31 | 32 | [tool.setuptools.dynamic] 33 | version = { attr = "codem.__version__" } 34 | 35 | [build-system] 36 | requires = ["setuptools>=64.0"] 37 | build-backend = "setuptools.build_meta" 38 | 39 | [project.scripts] 40 | codem = "codem.main:main" 41 | vcd = "vcd.main:main" 42 | 43 | [tool.black] 44 | line-length = 88 45 | target-version = ['py39'] 46 | include = '\.py(i|t)?$' 47 | exclude = ''' 48 | ( 49 | /( 50 | \.eggs # exclude a few common directories in the 51 | | \.git # root of the project 52 | | \.hg 53 | | \.mypy_cache 54 | | \.tox 55 | | \.venv* 56 | | _build 57 | | buck-out 58 | | build 59 | | dist 60 | )/ 61 | ) 62 | ''' 63 | 64 | [tool.mypy] 65 | plugins = 'numpy.typing.mypy_plugin' 66 | python_version = '3.9' 67 | warn_return_any = true 68 | disallow_untyped_defs = true 69 | disallow_untyped_calls = true 70 | disallow_incomplete_defs = true 71 | 72 | [[tool.mypy.overrides]] 73 | module = [ 74 | "cv2", 75 | "enlighten", 76 | "matplotlib", 77 | "matplotlib.colors", 78 | "matplotlib.pyplot", 79 | "matplotlib.tri", 80 | "pandas", 81 | "pdal", 82 | "pyproj", 83 | "pyproj.aoi", 84 | "pyproj.crs", 85 | "pyproj.database", 86 | "pyproj.enums", 87 | "pyproj.transformer", 88 | "pythonjsonlogger", 89 | "rasterio", 90 | "rasterio.coords", 91 | "rasterio.crs", 92 | "rasterio.errors", 93 | "rasterio.enums", 94 | "rasterio.fill", 95 | "rasterio.transform", 96 | "rasterio.warp", 97 | "rich", 98 | "rich.console", 99 | "rich.logging", 100 | "rich.progress", 101 | "scipy", 102 | "scipy.sparse", 103 | "scipy.spatial", 104 | "shapefile", 105 | "skimage", 106 | "skimage.measure", 107 | "trimesh", 108 | "websocket", 109 | ] 110 | ignore_missing_imports = true 111 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # CODEM: Multi-Modal Digital Elevation Model Registration 2 | 3 | ![Registered Mesh](./docs/img/reg_mesh.png) 4 | 5 | ## Overview 6 | 7 | `CODEM` is a testbed application for registering a 3D model of an area of interest (AOI) to a larger 3D Foundation data source. Point cloud, mesh, and raster digital surface model (DSM) data types are supported. Format support is limited to the following: 8 | 9 | * Point Cloud: LAS, LAZ, BPF 10 | * Mesh: PLY, OBJ 11 | * DSM: GeoTIFF 12 | 13 | `CODEM` follows the following basic steps to perform co-registration: 14 | 15 | 1. Generation of normalized DSMs from the AOI and Foundation data sources. 16 | 2. Coarse registration via matching of features extracted from the DSMs. 17 | 3. Fine registration via an iterative closest point (ICP) algorithm. 18 | 4. Application of the solved registration transformation to the AOI data in its original type and format. 19 | 20 | ## Installing CODEM 21 | 22 | 1. Clone the repo: 23 | 24 | ```console 25 | git clone https://github.com/NCALM-UH/CODEM 26 | ``` 27 | 28 | 2. Create and activate a Conda environment containing the required dependencies. From inside the `CODEM` directory: 29 | 30 | ```console 31 | conda env create --file environment.yml 32 | ``` 33 | 34 | ```console 35 | conda activate codem 36 | ``` 37 | 38 | 3. Install `CODEM`. From the project directory. 39 | 40 | ```console 41 | pip install . 42 | ``` 43 | 44 | ## CoRegistration 45 | 46 | ### Running CODEM 47 | 48 | The `CODEM` application has two required positional arguments and numerous options. The required positional arguments are the file path to the Foundation data file and the file path to the AOI data file. Executing codem on the command line has the following form: 49 | 50 | ```bash 51 | codem [-opt option_value] 52 | ``` 53 | 54 | For example, running `CODEM` on some of the sample data files in the [demo](demo) directory looks like: 55 | 56 | ```bash 57 | codem demo/Foundation-PointCloud.laz demo/AOI-Mesh.ply 58 | ``` 59 | 60 | Optional arguments can be placed before or after the positional arguments. For example, we can set the minimum registration pipeline resolution to a new value (default value = 1.0): 61 | 62 | ```bash 63 | codem demo/Foundation-PointCloud.laz demo/AOI-Mesh.ply --min_resolution 2.0 64 | ``` 65 | 66 | A summary of all options and their default values is given in the [docs/configuration.md](docs/configuration.md) document. The default option values should be sufficient for most landscapes. 67 | 68 | 69 | ## CODEM Generated Output 70 | 71 | All output is saved to a new directory that is created at the location of the AOI file. The directory name is tagged with the date and time of execution: `registration_YYYY-MM-DD_HH-MM-SS`. The directory contents include the following: 72 | 73 | 1. Registered AOI Data File: The registered AOI file will be of the same data type and file format as the original AOI file and will have the same name with term "`_registered`" appended to end of the name. 74 | 2. `config.yml`: A record of the parameters used in the registration. 75 | 3. `log.txt`: A log file. 76 | 4. `registration.txt`: Contains the solved coarse and fine registration transformation parameters and a few statistics. 77 | 5. `dsm_feature_matches.png`: An image of the matched features used in the coarse registration step. 78 | 79 | 80 | ## Vertical Change Detection 81 | 82 | ### Running VCD 83 | 84 | In addition to the coregistration functionality, codem provides vertical change detection functionality as well based on LiDAR scans. 85 | 86 | ```bash 87 | vcd [-opt option_value] 88 | ``` 89 | 90 | ### VCD Generated Output 91 | 92 | Raster, Mesh and Point cloud outputs are generated (inclyding ESRI 3D Shapefiles) to highlight ground/not-ground features, and vertical changes. 93 | 94 | 95 | ## Additional Information 96 | 97 | Information on available configuration options, a more in-depth review of how `CODEM` works, and a simple example utilizing data files contained in the `demo` directory of this repository are found in the `docs` directory: 98 | 99 | * [docs/configuration.md](docs/configuration.md) 100 | * [docs/details.md](docs/details.md) 101 | * [docs/example.md](docs/example.md) 102 | 103 | ## Contact 104 | 105 | * Ognyan Moore - Hobu Inc. - [Email](ogi@hobu.co) 106 | * Preston Hartzell - University of Houston - [Email](pjhartzell@uh.edu) 107 | * Jesse Shanahan - formerly of Booz Allen Hamilton (listed for software development credit attribution) - [LinkedIn](https://www.linkedin.com/in/jesseshanahan/) 108 | -------------------------------------------------------------------------------- /src/codem/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.26.1" 2 | 3 | import codem.lib.log as log 4 | import codem.lib.resources as resources 5 | from codem.main import apply_registration 6 | from codem.main import coarse_registration 7 | from codem.main import CodemRunConfig 8 | from codem.main import fine_registration 9 | from codem.main import preprocess 10 | -------------------------------------------------------------------------------- /src/codem/__main__.py: -------------------------------------------------------------------------------- 1 | from .main import main 2 | 3 | main() -------------------------------------------------------------------------------- /src/codem/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NCALM-UH/CODEM/2264bc062ad827ca0f4558b3d0fb5ad40a90dcb3/src/codem/lib/__init__.py -------------------------------------------------------------------------------- /src/codem/lib/console.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | class DummyConsole: 5 | """ 6 | Class that acts as a drop in replacement for rich's Console, in case 7 | rich is not present on the system, or we are not wanting rich output 8 | """ 9 | 10 | def __init__(self, *args: Any, **kwargs: Any) -> None: 11 | self.level = float("-inf") 12 | 13 | def print(self, *args: Any, **kwargs: Any) -> None: 14 | print(*args) 15 | -------------------------------------------------------------------------------- /src/codem/lib/log.py: -------------------------------------------------------------------------------- 1 | """ 2 | log.py 3 | Project: CRREL-NEGGS University of Houston Collaboration 4 | Date: February 2021 5 | 6 | A module for setting up logging. 7 | """ 8 | import logging 9 | import os 10 | from typing import Any 11 | from typing import Dict 12 | from typing import TYPE_CHECKING 13 | 14 | if TYPE_CHECKING: 15 | from codem.preprocessing import CodemParameters 16 | from vcd.preprocessing import VCDParameters 17 | 18 | try: 19 | import websocket 20 | from pythonjsonlogger import jsonlogger 21 | except ImportError: 22 | pass 23 | else: 24 | class CustomJsonFormatter(jsonlogger.JsonFormatter): 25 | def add_fields( 26 | self, 27 | log_record: Dict[str, Any], 28 | record: logging.LogRecord, 29 | message_dict: Dict[str, Any], 30 | ) -> None: 31 | super().add_fields(log_record, record, message_dict) 32 | if log_record.get("level"): 33 | log_record["level"] = log_record["level"].upper() 34 | else: 35 | log_record["level"] = record.levelname 36 | 37 | if log_record.get("type") is None: 38 | log_record["type"] = "log_message" 39 | return None 40 | 41 | 42 | class WebSocketHandler(logging.Handler): 43 | def __init__(self, level: str, websocket: "websocket.WebSocket") -> None: 44 | super().__init__(level) 45 | self.ws = websocket 46 | # TODO: check if websocket is already connected? 47 | 48 | def emit(self, record: logging.LogRecord) -> None: 49 | msg = self.format(record) 50 | _ = self.ws.send(msg) 51 | return None 52 | 53 | def close(self) -> None: 54 | self.ws.close() 55 | return super().close() 56 | 57 | 58 | class Log: 59 | def __init__(self, config: Dict[str, Any]): 60 | """ 61 | Creates logging formatting and structure 62 | 63 | Parameters 64 | ---------- 65 | config: 66 | Dictionary representing the runtime config 67 | """ 68 | 69 | self.logger = logging.getLogger("codem") 70 | self.logger.setLevel(logging.DEBUG) 71 | 72 | # disable loggers 73 | logging.getLogger("matplotlib.font_manager").disabled = True 74 | 75 | # File Handler for Logging 76 | log_format = logging.Formatter( 77 | "%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s" 78 | ) 79 | file_handler = logging.FileHandler( 80 | os.path.join(config.get("OUTPUT_DIR", "."), "log.txt") 81 | ) 82 | file_handler.setLevel(logging.DEBUG) 83 | file_handler.setFormatter(log_format) 84 | self.logger.addHandler(file_handler) 85 | self.relay = None 86 | 87 | # Supplemental Handler 88 | if config["LOG_TYPE"] == "rich": 89 | from rich.logging import RichHandler 90 | 91 | log_handler = RichHandler() 92 | elif config["LOG_TYPE"] == "websocket": 93 | formatter = CustomJsonFormatter() 94 | self.relay = websocket.WebSocket() 95 | url = f'ws://{config["WEBSOCKET_URL"]}/websocket' 96 | try: 97 | self.relay.connect(url) 98 | except ConnectionRefusedError as err: 99 | raise ConnectionRefusedError(f"Connection Refused to {url}") 100 | log_handler = WebSocketHandler("DEBUG", websocket=self.relay) 101 | log_handler.setFormatter(formatter) 102 | else: 103 | log_handler = logging.StreamHandler() 104 | log_handler.setLevel("DEBUG") 105 | self.logger.addHandler(log_handler) 106 | 107 | def __del__(self) -> None: 108 | if isinstance(self.logger, WebSocketHandler): 109 | self.logger.close() 110 | -------------------------------------------------------------------------------- /src/codem/lib/progress.py: -------------------------------------------------------------------------------- 1 | import json 2 | from contextlib import ContextDecorator 3 | from typing import Any 4 | from typing import Dict 5 | 6 | 7 | try: 8 | import websocket 9 | except ImportError: 10 | pass 11 | else: 12 | 13 | class WebSocketProgress(ContextDecorator): 14 | def __init__(self, url: str) -> None: 15 | super().__init__() 16 | self.ws = websocket.WebSocket() 17 | self.tasks: Dict[str, int] = {} 18 | self.current: Dict[str, int] = {} 19 | self.url = url 20 | 21 | def __enter__(self) -> Any: 22 | url = f'ws://{self.url}/websocket' 23 | try: 24 | self.ws.connect(url) 25 | except ConnectionRefusedError as err: 26 | raise ConnectionRefusedError(f"Connection Refused to {url}") 27 | return self 28 | 29 | def __exit__(self, *args: Any, **kwargs: Any) -> None: 30 | self.ws.close() 31 | return None 32 | 33 | def advance(self, name: str, value: int) -> None: 34 | self.current[name] += value 35 | new_value = self.current[name] 36 | self.ws.send(json.dumps({"advance": new_value, "type": "progress"})) 37 | return None 38 | 39 | def add_task(self, title: str, total: int) -> str: 40 | self.tasks[title] = total 41 | self.current[title] = 0 42 | return title 43 | -------------------------------------------------------------------------------- /src/codem/lib/resources.py: -------------------------------------------------------------------------------- 1 | """ 2 | resources.py 3 | Project: CRREL-NEGGS University of Houston Collaboration 4 | Date: February 2021 5 | 6 | Supported filetypes 7 | """ 8 | 9 | dsm_filetypes = [".vrt", ".tif"] 10 | pcloud_filetypes = [".las", ".laz", ".bpf", ".json"] 11 | mesh_filetypes = [".ply", ".obj"] 12 | -------------------------------------------------------------------------------- /src/codem/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | main.py 3 | Project: CRREL-NEGGS University of Houston Collaboration 4 | Date: February 2021 5 | 6 | The main script for running a two-step co-registration. A feature-based 7 | global registration operating on DSMs is followed by a local ICP point 8 | to plane registration. Logs for each registration run can be found in 9 | the logs/ directory, and the outputs (text and image) can be found in 10 | the relevant run directory within outputs/. 11 | """ 12 | import argparse 13 | import dataclasses 14 | import math 15 | import os 16 | import time 17 | import warnings 18 | from contextlib import ContextDecorator 19 | from typing import Any 20 | from typing import List 21 | from typing import Optional 22 | from typing import Tuple 23 | 24 | import yaml 25 | from codem import __version__ 26 | from codem.lib.log import Log 27 | from codem.preprocessing.preprocess import clip_data 28 | from codem.preprocessing.preprocess import CodemParameters 29 | from codem.preprocessing.preprocess import GeoData 30 | from codem.preprocessing.preprocess import instantiate 31 | from codem.registration import ApplyRegistration 32 | from codem.registration import DsmRegistration 33 | from codem.registration import IcpRegistration 34 | from distutils.util import strtobool 35 | 36 | 37 | class DummyProgress(ContextDecorator): 38 | def __init__(self, *args: Any, **kwargs: Any) -> None: 39 | super().__init__() 40 | 41 | def __enter__(self, *args: Any, **kwargs: Any) -> Any: 42 | return self 43 | 44 | def __exit__(self, *args: Any, **kwargs: Any) -> None: 45 | pass 46 | 47 | def add_task(self, *args: Any, **kwargs: Any) -> None: 48 | pass 49 | 50 | def advance(self, *args: Any, **kwargs: Any) -> None: 51 | pass 52 | 53 | @staticmethod 54 | def get_default_columns() -> List: 55 | return [] 56 | 57 | 58 | @dataclasses.dataclass 59 | class CodemRunConfig: 60 | FND_FILE: str 61 | AOI_FILE: str 62 | MIN_RESOLUTION: float = float("nan") 63 | DSM_AKAZE_THRESHOLD: float = 0.0001 64 | DSM_LOWES_RATIO: float = 0.9 65 | DSM_RANSAC_MAX_ITER: int = 10000 66 | DSM_RANSAC_THRESHOLD: float = 10.0 67 | DSM_SOLVE_SCALE: bool = True 68 | DSM_STRONG_FILTER: float = 10.0 69 | DSM_WEAK_FILTER: float = 1.0 70 | ICP_ANGLE_THRESHOLD: float = 0.001 71 | ICP_DISTANCE_THRESHOLD: float = 0.001 72 | ICP_MAX_ITER: int = 100 73 | ICP_RMSE_THRESHOLD: float = 0.0001 74 | ICP_ROBUST: bool = True 75 | ICP_SOLVE_SCALE: bool = True 76 | OFFSET_X: str= 'auto' 77 | OFFSET_Y: str = 'auto' 78 | OFFSET_Z: str = 'auto' 79 | SCALE_X: str = "0.01" 80 | SCALE_Y: str = "0.01" 81 | SCALE_Z: str = "0.01" 82 | VERBOSE: bool = False 83 | ICP_SAVE_RESIDUALS: bool = False 84 | OUTPUT_DIR: Optional[str] = None 85 | TIGHT_SEARCH: bool = False 86 | LOG_TYPE: str = "rich" 87 | WEBSOCKET_URL: str = "127.0.0.1:8889" 88 | 89 | def __post_init__(self) -> None: 90 | # set output directory 91 | if self.OUTPUT_DIR is None: 92 | current_time = time.localtime(time.time()) 93 | timestamp = "%d-%02d-%02d_%02d-%02d-%02d" % ( 94 | current_time.tm_year, 95 | current_time.tm_mon, 96 | current_time.tm_mday, 97 | current_time.tm_hour, 98 | current_time.tm_min, 99 | current_time.tm_sec, 100 | ) 101 | 102 | output_dir = os.path.join( 103 | os.path.dirname(self.AOI_FILE), f"registration_{timestamp}" 104 | ) 105 | os.mkdir(output_dir) 106 | self.OUTPUT_DIR = os.path.abspath(output_dir) 107 | 108 | # validate attributes 109 | if not os.path.exists(self.FND_FILE): 110 | raise FileNotFoundError(f"Foundation file {self.FND_FILE} not found.") 111 | if not os.path.exists(self.AOI_FILE): 112 | raise FileNotFoundError(f"AOI file {self.AOI_FILE} not found.") 113 | if self.MIN_RESOLUTION <= 0: 114 | raise ValueError("Minimum pipeline resolution must be a greater than 0.") 115 | if self.DSM_AKAZE_THRESHOLD <= 0: 116 | raise ValueError("Minmum AKAZE threshold must be greater than 0.") 117 | if self.DSM_LOWES_RATIO < 0.01 or self.DSM_LOWES_RATIO >= 1.0: 118 | raise ValueError("Lowes ratio must be between 0.01 and 1.0.") 119 | if self.DSM_RANSAC_MAX_ITER < 1: 120 | raise ValueError( 121 | "Maximum number of RANSAC iterations must be a positive integer." 122 | ) 123 | if self.DSM_RANSAC_THRESHOLD <= 0: 124 | raise ValueError("RANSAC threshold must be a positive number.") 125 | if self.DSM_STRONG_FILTER <= 0: 126 | raise ValueError("DSM strong filter size must be greater than 0.") 127 | if self.DSM_WEAK_FILTER <= 0: 128 | raise ValueError("DSM weak filter size must be greater than 0.") 129 | if self.ICP_ANGLE_THRESHOLD <= 0: 130 | raise ValueError( 131 | "ICP minimum angle convergence threshold must be greater than 0." 132 | ) 133 | if self.ICP_DISTANCE_THRESHOLD <= 0: 134 | raise ValueError( 135 | "ICP minimum distance convergence threshold must be greater than 0." 136 | ) 137 | if self.ICP_MAX_ITER < 1: 138 | raise ValueError( 139 | "Maximum number of ICP iterations must be a positive integer." 140 | ) 141 | if self.ICP_RMSE_THRESHOLD <= 0: 142 | raise ValueError( 143 | "ICP minimum change in RMSE convergence threshold must be greater than 0." 144 | ) 145 | for offset in [self.OFFSET_X, self.OFFSET_Y, self.OFFSET_Z]: 146 | if ( 147 | offset != "auto" 148 | and not offset.isnumeric() 149 | ): 150 | raise ValueError( 151 | "Offset values need to be set to 'auto' or an integer" 152 | ) 153 | for scale in [self.SCALE_X, self.SCALE_Y, self.SCALE_Z]: 154 | if ( 155 | isinstance(scale, str) 156 | and scale != "auto" 157 | ): 158 | try: 159 | float(scale) 160 | except ValueError as e: 161 | raise ValueError( 162 | "Offset values need to be set to 'auto' or an float" 163 | ) from e 164 | 165 | # dump config 166 | config_path = os.path.join(self.OUTPUT_DIR, "config.yml") 167 | with open(config_path, "w") as f: 168 | yaml.safe_dump( 169 | dataclasses.asdict(self), 170 | f, 171 | default_flow_style=False, 172 | sort_keys=False, 173 | explicit_start=True, 174 | ) 175 | return None 176 | 177 | 178 | def str2bool(v: str) -> bool: 179 | return bool(strtobool(v)) 180 | 181 | 182 | def get_args() -> argparse.Namespace: 183 | ap = argparse.ArgumentParser( 184 | description="CODEM: Multi-Modal Digital Elevation Model Registration" 185 | ) 186 | ap.add_argument( 187 | "foundation_file", 188 | type=str, 189 | help="path to the foundation file", 190 | ) 191 | ap.add_argument( 192 | "aoi_file", 193 | type=str, 194 | help="path to the area of interest file", 195 | ) 196 | ap.add_argument( 197 | "--min-resolution", 198 | "-min", 199 | type=float, 200 | default=CodemRunConfig.MIN_RESOLUTION, 201 | help="minimum pipeline data resolution", 202 | ) 203 | ap.add_argument( 204 | "--dsm-akaze-threshold", 205 | "-dat", 206 | type=float, 207 | default=0.0001, 208 | help="AKAZE feature detection response threshold", 209 | ) 210 | ap.add_argument( 211 | "--dsm-lowes-ratio", 212 | "-dlr", 213 | type=float, 214 | default=0.9, 215 | help="feature matching relative strength control", 216 | ) 217 | ap.add_argument( 218 | "--dsm-ransac-max-iter", 219 | "-drmi", 220 | type=int, 221 | default=10000, 222 | help="max iterations for the RANSAC algorithm", 223 | ) 224 | ap.add_argument( 225 | "--dsm-ransac-threshold", 226 | "-drt", 227 | type=float, 228 | default=10, 229 | help="maximum residual error for a feature matched pair to be included in RANSAC solution", 230 | ) 231 | ap.add_argument( 232 | "--dsm-solve-scale", 233 | "-dss", 234 | type=str2bool, 235 | default=True, 236 | help="boolean to include or exclude scale from the solved registration transformation", 237 | ) 238 | ap.add_argument( 239 | "--dsm-strong-filter", 240 | "-dsf", 241 | type=float, 242 | default=10, 243 | help="stddev of the large Gaussian filter used to normalize DSM prior to feature extraction", 244 | ) 245 | ap.add_argument( 246 | "--dsm-weak-filter", 247 | "-dwf", 248 | type=float, 249 | default=1, 250 | help="stddev of the small Gaussian filter used to normalize the DSM prior to feature extraction", 251 | ) 252 | ap.add_argument( 253 | "--icp-angle-threshold", 254 | "-iat", 255 | type=float, 256 | default=0.001, 257 | help="minimum change in Euler angle between ICP iterations", 258 | ) 259 | ap.add_argument( 260 | "--icp-distance-threshold", 261 | "-idt", 262 | type=float, 263 | default=0.001, 264 | help="minimum change in translation between ICP iterations", 265 | ) 266 | ap.add_argument( 267 | "--icp-max-iter", 268 | "-imi", 269 | type=int, 270 | default=100, 271 | help="max iterations of the ICP algorithm", 272 | ) 273 | ap.add_argument( 274 | "--icp-rmse-threshold", 275 | "-irt", 276 | type=float, 277 | default=0.0001, 278 | help="minimum relative change between iterations in the RMSE", 279 | ) 280 | ap.add_argument( 281 | "--icp-robust", 282 | "-ir", 283 | type=str2bool, 284 | default=True, 285 | help="boolean to include or exclude robust weighting in registration solution", 286 | ) 287 | ap.add_argument( 288 | "--icp-solve-scale", 289 | "-iss", 290 | type=str2bool, 291 | default=True, 292 | help="boolean to include or exclude scale from the solved registration", 293 | ) 294 | ap.add_argument( 295 | "--icp-save-residuals", 296 | action="store_true", 297 | help="Write ICP residual information", 298 | ) 299 | ap.add_argument( 300 | "--offset-x", 301 | type=str, 302 | default='auto', 303 | help=( 304 | "Offset to be subtracted from the X nominal value, before " 305 | "the value is scaled. The special value auto can be specified, which causes " 306 | "the writer to set the offset to the minimum value of the dimension. " 307 | ) 308 | ) 309 | ap.add_argument( 310 | "--offset-y", 311 | type=str, 312 | default='auto', 313 | help=( 314 | "Offset to be subtracted from the Y nominal value, before " 315 | "the value is scaled. The special value auto can be specified, which causes " 316 | "the writer to set the offset to the minimum value of the dimension. " 317 | ) 318 | ) 319 | ap.add_argument( 320 | "--offset-z", 321 | type=str, 322 | default='auto', 323 | help=( 324 | "Offset to be subtracted from the Z nominal value, before " 325 | "the value is scaled. The special value auto can be specified, which causes " 326 | "the writer to set the offset to the minimum value of the dimension. " 327 | ) 328 | ) 329 | ap.add_argument( 330 | "--scale-x", 331 | type=str, 332 | default='.01', 333 | help=( 334 | "Scale to be divided from the X nominal value, " 335 | "after the offset has been applied. The special value auto can be specified, " 336 | "which causes the writer to select a scale to set the stored values of the " 337 | "dimensions to range from [0, 2147483647]." 338 | ) 339 | ) 340 | ap.add_argument( 341 | "--scale-y", 342 | type=str, 343 | default='.01', 344 | help=( 345 | "Scale to be divided from the Y nominal value, " 346 | "after the offset has been applied. The special value auto can be specified, " 347 | "which causes the writer to select a scale to set the stored values of the " 348 | "dimensions to range from [0, 2147483647]." 349 | ) 350 | ) 351 | ap.add_argument( 352 | "--scale-z", 353 | type=str, 354 | default='.01', 355 | help=( 356 | "Scale to be divided from the Z nominal value, " 357 | "after the offset has been applied. The special value auto can be specified, " 358 | "which causes the writer to select a scale to set the stored values of the " 359 | "dimensions to range from [0, 2147483647]." 360 | ) 361 | ) 362 | ap.add_argument( 363 | "--verbose", "-v", action="store_true", help="turn on verbose logging" 364 | ) 365 | ap.add_argument( 366 | "--tight-search", 367 | "-ts", 368 | action="store_true", 369 | help=( 370 | "Limits the registration search to the region of overlap. Both datasets " 371 | "must have the same CRS defined." 372 | ), 373 | ) 374 | ap.add_argument( 375 | "--output-dir", "-o", type=str, help="Directory to place registered output." 376 | ) 377 | ap.add_argument( 378 | "--version", 379 | action="version", 380 | version=f"{__version__}", 381 | help="Display codem version information", 382 | ) 383 | ap.add_argument( 384 | "--log-type", 385 | "-l", 386 | type=str, 387 | default=CodemRunConfig.LOG_TYPE, 388 | help="Specify how to log codem output, options include websockets, rich or console", 389 | ) 390 | ap.add_argument( 391 | "--websocket-url", 392 | type=str, 393 | default=CodemRunConfig.WEBSOCKET_URL, 394 | help="Url to websocket receiver to connect to", 395 | ) 396 | return ap.parse_args() 397 | 398 | 399 | def create_config(args: argparse.Namespace) -> CodemParameters: 400 | config = CodemRunConfig( 401 | os.fsdecode(os.path.abspath(args.foundation_file)), 402 | os.fsdecode(os.path.abspath(args.aoi_file)), 403 | MIN_RESOLUTION=float(args.min_resolution), 404 | DSM_AKAZE_THRESHOLD=float(args.dsm_akaze_threshold), 405 | DSM_LOWES_RATIO=float(args.dsm_lowes_ratio), 406 | DSM_RANSAC_MAX_ITER=int(args.dsm_ransac_max_iter), 407 | DSM_RANSAC_THRESHOLD=float(args.dsm_ransac_threshold), 408 | DSM_SOLVE_SCALE=args.dsm_solve_scale, 409 | DSM_STRONG_FILTER=float(args.dsm_strong_filter), 410 | DSM_WEAK_FILTER=float(args.dsm_weak_filter), 411 | ICP_ANGLE_THRESHOLD=float(args.icp_angle_threshold), 412 | ICP_DISTANCE_THRESHOLD=float(args.icp_distance_threshold), 413 | ICP_MAX_ITER=int(args.icp_max_iter), 414 | ICP_RMSE_THRESHOLD=float(args.icp_rmse_threshold), 415 | ICP_ROBUST=args.icp_robust, 416 | ICP_SOLVE_SCALE=args.icp_solve_scale, 417 | SCALE_X=args.scale_x, 418 | SCALE_Y=args.scale_y, 419 | SCALE_Z=args.scale_z, 420 | OFFSET_X=args.offset_x, 421 | OFFSET_Y=args.offset_y, 422 | OFFSET_Z=args.offset_z, 423 | VERBOSE=args.verbose, 424 | ICP_SAVE_RESIDUALS=args.icp_save_residuals, 425 | TIGHT_SEARCH=args.tight_search, 426 | OUTPUT_DIR=args.output_dir, 427 | LOG_TYPE=args.log_type, 428 | WEBSOCKET_URL=args.websocket_url 429 | ) 430 | config_dict = dataclasses.asdict(config) 431 | log = Log(config_dict) 432 | config_dict["log"] = log 433 | return config_dict # type: ignore 434 | 435 | 436 | def run_rich_console( 437 | config: CodemParameters, 438 | ) -> None: 439 | """ 440 | Preprocess and register the provided data 441 | 442 | Parameters 443 | ---------- 444 | config 445 | Dictionary of configuration parameters 446 | """ 447 | from rich.console import Console # type: ignore 448 | from rich.progress import Progress # type: ignore 449 | from rich.progress import SpinnerColumn # type: ignore 450 | from rich.progress import TimeElapsedColumn # type: ignore 451 | 452 | console = Console() 453 | logger = config["log"].logger 454 | 455 | with Progress( 456 | SpinnerColumn(), 457 | *Progress.get_default_columns(), 458 | TimeElapsedColumn(), 459 | console=console, 460 | ) as progress: 461 | registration = progress.add_task("Registration...", total=100) 462 | 463 | console.print("/************************************\\", justify="center") 464 | console.print("* CODEM *", justify="center") 465 | console.print("**************************************", justify="center") 466 | console.print("* AUTHORS: Preston Hartzell & *", justify="center") 467 | console.print("* Jesse Shanahan *", justify="center") 468 | console.print("* DEVELOPED FOR: CRREL/NEGGS *", justify="center") 469 | console.print("\\************************************/", justify="center") 470 | console.print() 471 | console.print("===========PARAMETERS===========", justify="center") 472 | for key, value in config.items(): 473 | logger.info(f"{key} = {value}") 474 | progress.advance(registration, 1) 475 | 476 | console.print("===========PREPROCESSING DATA===========", justify="center") 477 | fnd_obj, aoi_obj = preprocess(config) 478 | clip_data(fnd_obj, aoi_obj, config) 479 | progress.advance(registration, 7) 480 | fnd_obj.prep() 481 | progress.advance(registration, 45) 482 | aoi_obj.prep() 483 | progress.advance(registration, 4) 484 | logger.info( 485 | f"Registration resolution has been set to: {fnd_obj.resolution} meters" 486 | ) 487 | 488 | console.print( 489 | "===========BEGINNING COARSE REGISTRATION===========", justify="center" 490 | ) 491 | dsm_reg = coarse_registration(fnd_obj, aoi_obj, config) 492 | progress.advance(registration, 22) 493 | 494 | console.print( 495 | "===========BEGINNING FINE REGISTRATION===========", justify="center" 496 | ) 497 | icp_reg = fine_registration(fnd_obj, aoi_obj, dsm_reg, config) 498 | progress.advance(registration, 16) 499 | 500 | console.print("===========APPLYING REGISTRATION===========", justify="center") 501 | apply_registration(fnd_obj, aoi_obj, icp_reg, config) 502 | progress.advance(registration, 5) 503 | 504 | 505 | def run_stdout_console(config: CodemParameters) -> None: 506 | """ 507 | Preprocess and register the provided data 508 | 509 | Parameters 510 | ---------- 511 | config: dict 512 | Dictionary of configuration parameters 513 | """ 514 | 515 | # registration = progress.add_task("Registration...", total=100) 516 | 517 | logger = config["log"].logger 518 | 519 | print("/************************************\\") 520 | print("* CODEM *") 521 | print("**************************************") 522 | print("* AUTHORS: Preston Hartzell & *") 523 | print("* Jesse Shanahan *") 524 | print("* DEVELOPED FOR: CRREL/NEGGS *") 525 | print("\\************************************/") 526 | print() 527 | print("===========PARAMETERS===========") 528 | for key, value in config.items(): 529 | logger.info(f"{key} = {value}") 530 | 531 | print("===========PREPROCESSING DATA===========") 532 | fnd_obj, aoi_obj = preprocess(config) 533 | clip_data(fnd_obj, aoi_obj, config) 534 | fnd_obj.prep() 535 | aoi_obj.prep() 536 | logger.info(f"Registration resolution has been set to: {fnd_obj.resolution} meters") 537 | 538 | print("===========BEGINNING COARSE REGISTRATION===========") 539 | dsm_reg = coarse_registration(fnd_obj, aoi_obj, config) 540 | 541 | print("===========BEGINNING FINE REGISTRATION===========") 542 | icp_reg = fine_registration(fnd_obj, aoi_obj, dsm_reg, config) 543 | 544 | print("===========APPLYING REGISTRATION===========") 545 | apply_registration(fnd_obj, aoi_obj, icp_reg, config) 546 | 547 | 548 | def run_no_console( 549 | config: CodemParameters, 550 | ) -> None: 551 | """ 552 | Preprocess and register the provided data 553 | 554 | Parameters 555 | ---------- 556 | config: dict 557 | Dictionary of configuration parameters 558 | """ 559 | 560 | from codem.lib.progress import WebSocketProgress 561 | 562 | logger = config["log"].logger 563 | 564 | with WebSocketProgress(config["WEBSOCKET_URL"]) as progress: 565 | registration = progress.add_task("Registration...", total=100) 566 | 567 | for key, value in config.items(): 568 | logger.info(f"{key} = {value}") 569 | progress.advance(registration, 1) 570 | 571 | fnd_obj, aoi_obj = preprocess(config) 572 | clip_data(fnd_obj, aoi_obj, config) 573 | progress.advance(registration, 7) 574 | fnd_obj.prep() 575 | progress.advance(registration, 45) 576 | aoi_obj.prep() 577 | progress.advance(registration, 4) 578 | logger.info( 579 | f"Registration resolution has been set to: {fnd_obj.resolution} meters" 580 | ) 581 | 582 | dsm_reg = coarse_registration(fnd_obj, aoi_obj, config) 583 | progress.advance(registration, 22) 584 | 585 | icp_reg = fine_registration(fnd_obj, aoi_obj, dsm_reg, config) 586 | progress.advance(registration, 16) 587 | 588 | apply_registration(fnd_obj, aoi_obj, icp_reg, config) 589 | progress.advance(registration, 5) 590 | 591 | 592 | def preprocess(config: CodemParameters) -> Tuple[GeoData, GeoData]: 593 | fnd_obj = instantiate(config, fnd=True) 594 | aoi_obj = instantiate(config, fnd=False) 595 | if not math.isnan(config["MIN_RESOLUTION"]): 596 | resolution = config["MIN_RESOLUTION"] 597 | if resolution > max(fnd_obj.native_resolution, aoi_obj.native_resolution): 598 | warnings.warn( 599 | "Specified resolution is a coarser value in than either the " 600 | "foundation or AOI, registration may fail as a result. Consider " 601 | "leaving the min_resolution parameter to default value.", 602 | UserWarning, 603 | stacklevel=2, 604 | ) 605 | else: 606 | resolution = max(fnd_obj.native_resolution, aoi_obj.native_resolution) 607 | fnd_obj.resolution = aoi_obj.resolution = resolution 608 | 609 | # create DSM, but if doing tight-search do not resample 610 | resample = not config["TIGHT_SEARCH"] 611 | fnd_obj._create_dsm(resample=resample) 612 | aoi_obj._create_dsm(resample=resample, fallback_crs=fnd_obj.crs) 613 | return fnd_obj, aoi_obj 614 | 615 | 616 | def coarse_registration( 617 | fnd_obj: GeoData, aoi_obj: GeoData, config: CodemParameters 618 | ) -> DsmRegistration: 619 | dsm_reg = DsmRegistration(fnd_obj, aoi_obj, config) 620 | dsm_reg.register() 621 | return dsm_reg 622 | 623 | 624 | def fine_registration( 625 | fnd_obj: GeoData, 626 | aoi_obj: GeoData, 627 | dsm_reg: DsmRegistration, 628 | config: CodemParameters, 629 | ) -> IcpRegistration: 630 | icp_reg = IcpRegistration(fnd_obj, aoi_obj, dsm_reg, config) 631 | icp_reg.register() 632 | return icp_reg 633 | 634 | 635 | def apply_registration( 636 | fnd_obj: GeoData, 637 | aoi_obj: GeoData, 638 | icp_reg: IcpRegistration, 639 | config: CodemParameters, 640 | output_format: Optional[str] = None, 641 | ) -> str: 642 | app_reg = ApplyRegistration( 643 | fnd_obj, 644 | aoi_obj, 645 | icp_reg.registration_parameters, 646 | icp_reg.residual_vectors, 647 | icp_reg.residual_origins, 648 | config, 649 | output_format, 650 | ) 651 | app_reg.apply() 652 | return app_reg.out_name 653 | 654 | 655 | def main() -> None: 656 | args = get_args() 657 | config = create_config(args) 658 | 659 | if config["LOG_TYPE"] == "rich": 660 | run_rich_console(config) 661 | elif config["LOG_TYPE"] == "websocket": 662 | run_no_console(config) 663 | config["log"].logger.info("run no console has finished") 664 | else: 665 | run_stdout_console(config) 666 | 667 | 668 | if __name__ == "__main__": 669 | main() 670 | -------------------------------------------------------------------------------- /src/codem/preprocessing/__init__.py: -------------------------------------------------------------------------------- 1 | from .preprocess import CodemParameters 2 | -------------------------------------------------------------------------------- /src/codem/registration/__init__.py: -------------------------------------------------------------------------------- 1 | from .apply import ApplyRegistration 2 | from .dsm import DsmRegistration 3 | from .icp import IcpRegistration 4 | -------------------------------------------------------------------------------- /src/codem/registration/apply.py: -------------------------------------------------------------------------------- 1 | """ 2 | ApplyRegistration.py 3 | Project: CRREL-NEGGS University of Houston Collaboration 4 | Date: February 2021 5 | 6 | Applies the solved registration parameters to the original AOI data file. 7 | 8 | This module contains the following class: 9 | 10 | * ApplyRegistration: a class for applying registration results to the original 11 | unregistered AOI data file 12 | """ 13 | import json 14 | import logging 15 | import os 16 | from typing import Optional 17 | from typing import Tuple 18 | from typing import Union 19 | 20 | import codem.lib.resources as r 21 | import numpy as np 22 | import numpy.typing as npt 23 | import pdal 24 | import rasterio 25 | import trimesh 26 | from codem import __version__ 27 | from codem.preprocessing.preprocess import CodemParameters 28 | from codem.preprocessing.preprocess import GeoData 29 | from codem.preprocessing.preprocess import RegistrationParameters 30 | from matplotlib.tri import LinearTriInterpolator 31 | from matplotlib.tri import Triangulation 32 | from numpy.lib import recfunctions as rfn 33 | 34 | 35 | class ApplyRegistration: 36 | """ 37 | A class to apply the solved registration to the original AOI data file. 38 | 39 | Parameters 40 | ---------- 41 | fnd_obj: GeoData object 42 | The foundation data object 43 | aoi_obj: GeoData object 44 | The area of interest data object 45 | registration_parameters: 46 | Registration parameters from IcpRegistration 47 | residual_vectors: np.array 48 | Point to plane direction used in final ICP iteration 49 | residual_origins: np.array 50 | Origins of moving point used in final ICP iteration 51 | config: dict 52 | Dictionary of configuration options 53 | output_format: Optional[str] 54 | Provide file extension to be used for the output format 55 | 56 | Methods 57 | ------- 58 | get_registration_transformation 59 | apply 60 | _apply_dsm 61 | _apply_mesh 62 | _apply_pointcloud 63 | _interpolate_residuals 64 | """ 65 | 66 | def __init__( 67 | self, 68 | fnd_obj: GeoData, 69 | aoi_obj: GeoData, 70 | registration_parameters: RegistrationParameters, 71 | residual_vectors: npt.NDArray, 72 | residual_origins: npt.NDArray, 73 | config: CodemParameters, 74 | output_format: Optional[str], 75 | ) -> None: 76 | self.logger = logging.getLogger(__name__) 77 | self.fnd_crs = fnd_obj.crs 78 | self.fnd_units_factor = fnd_obj.units_factor 79 | self.fnd_units = fnd_obj.units 80 | self.aoi_file = aoi_obj.file 81 | self.aoi_nodata = aoi_obj.nodata 82 | self.aoi_resolution = aoi_obj.native_resolution 83 | self.aoi_crs = aoi_obj.crs 84 | self.aoi_units_factor = aoi_obj.units_factor 85 | self.aoi_type = aoi_obj.type 86 | self.aoi_area_or_point = aoi_obj.area_or_point 87 | self.registration_transform = registration_parameters["matrix"] 88 | self.registration_rmse = registration_parameters["rmse_3d"] 89 | self.residual_vectors = residual_vectors 90 | self.residual_origins = residual_origins 91 | self.config = config 92 | 93 | in_name = os.path.basename(self.aoi_file) 94 | root, ext = os.path.splitext(in_name) 95 | if output_format is not None: 96 | ext = ( 97 | output_format if output_format.startswith(".") else f".{output_format}" 98 | ) 99 | out_name = f"{root}_registered{ext}" 100 | self.out_name: str = os.path.join(self.config["OUTPUT_DIR"], out_name) 101 | 102 | def get_registration_transformation( 103 | self, 104 | ) -> Union[np.ndarray, pdal.Pipeline]: 105 | """ 106 | Generates the transformation from the AOI to FND coordinate system. 107 | The transformation accommodates linear unit differences and the solved 108 | registration matrix, which is only valid for linear units of meters. 109 | 110 | Returns 111 | -------- 112 | registration_transformation: 113 | np.ndarray : Registration matrix 114 | dict : PDAL filters.transformation stage with SRS overide if available 115 | """ 116 | aoi_to_meters = np.eye(4) * self.aoi_units_factor 117 | aoi_to_meters[3, 3] = 1 118 | meters_to_fnd = np.eye(4) * (1 / self.fnd_units_factor) 119 | meters_to_fnd[3, 3] = 1 120 | 121 | aoi_to_fnd_array: npt.NDArray = ( 122 | meters_to_fnd @ self.registration_transform @ aoi_to_meters 123 | ) 124 | 125 | if self.aoi_type == "mesh": 126 | return aoi_to_fnd_array 127 | else: 128 | aoi_to_fnd_array = np.reshape(aoi_to_fnd_array, (1, 16)) 129 | aoi_to_fnd_string = [ 130 | " ".join(item) for item in aoi_to_fnd_array.astype(str) 131 | ][0] 132 | registration_transformation = pdal.Filter.transformation( 133 | matrix=aoi_to_fnd_string 134 | ) 135 | return registration_transformation 136 | 137 | def apply(self) -> None: 138 | """ 139 | Call the appropriate registration function depending on data type 140 | """ 141 | if os.path.splitext(self.aoi_file)[-1] in r.dsm_filetypes: 142 | self._apply_dsm() 143 | if os.path.splitext(self.aoi_file)[-1] in r.mesh_filetypes: 144 | self._apply_mesh() 145 | if os.path.splitext(self.aoi_file)[-1] in r.pcloud_filetypes: 146 | self._apply_pointcloud() 147 | 148 | def _apply_dsm(self) -> None: 149 | """ 150 | Applies the registration transformation to a dsm file. 151 | We do not simply edit the transform of the DSM file because that is 152 | generally used to express 2D information. Instead, we apply the solved 153 | 3D transformation to the 2.5D data and "re-raster" it. 154 | """ 155 | input_name = os.path.basename(self.aoi_file) 156 | root, ext = os.path.splitext(input_name) 157 | output_name = f"{root}_registered{ext}" 158 | output_path = os.path.join(self.config["OUTPUT_DIR"], output_name) 159 | # construct pdal pipeline 160 | pipeline = pdal.Reader.gdal( 161 | filename=self.aoi_file, 162 | header="Z", 163 | ) 164 | 165 | # no nodata is present, filter based on its limits 166 | if self.aoi_nodata is not None: 167 | pipeline |= pdal.Filter.range( 168 | limits=f"Z![{self.aoi_nodata}:{self.aoi_nodata}]" 169 | ) 170 | 171 | # handle the case where the AOI underwent a CRS change 172 | if self.aoi_crs is not None: # if statement to satisfy mypy 173 | pipeline |= pdal.Filter.reprojection(out_srs=self.aoi_crs.to_wkt()) 174 | 175 | # insert the transform filter to register the AOI 176 | registration_task = self.get_registration_transformation() 177 | if isinstance(registration_task, pdal.pipeline.Filter): 178 | pipeline |= registration_task 179 | else: 180 | raise ValueError( 181 | f"get_registration_transformation returned {type(registration_task)} " 182 | "not a dictionary of strings as needed for the pdal pipeline." 183 | ) 184 | 185 | writer_kwargs = { 186 | "resolution": self.aoi_resolution, 187 | "output_type": "idw", 188 | "filename": output_path, 189 | "metadata": ( 190 | f"CODEM_VERSION={__version__}," 191 | "CODEM_INFO=Data registered and adjusted to " 192 | f"{os.path.basename(self.config['FND_FILE'])} by NCALM CODEM. " 193 | f"Total registration mean square error {self.registration_rmse:.3f}," 194 | "TIFFTAG_IMAGEDESCRIPTION=RegisteredCompliment" 195 | ), 196 | } 197 | 198 | if self.aoi_area_or_point in ("Area", "Point"): 199 | writer_kwargs["metadata"] += f",AREA_OR_POINT={self.aoi_area_or_point}" # type: ignore 200 | 201 | if self.aoi_nodata is not None: 202 | writer_kwargs["nodata"] = self.aoi_nodata 203 | 204 | pipeline |= pdal.Writer.gdal(**writer_kwargs) 205 | pipeline.execute() 206 | 207 | self.logger.info( 208 | f"Registration has been applied to AOI-DSM and saved to: {self.out_name}" 209 | ) 210 | if self.config["ICP_SAVE_RESIDUALS"]: 211 | with rasterio.open(self.out_name) as src: 212 | dsm = src.read(1) 213 | transform = src.transform 214 | nodata = src.nodata 215 | tags = src.tags() 216 | if "AREA_OR_POINT" in tags and tags["AREA_OR_POINT"] == "Area": 217 | area_or_point = "Area" 218 | elif "AREA_OR_POINT" in tags and tags["AREA_OR_POINT"] == "Point": 219 | area_or_point = "Point" 220 | else: 221 | area_or_point = "Area" 222 | profile = src.profile 223 | 224 | rows = np.arange(dsm.shape[0], dtype=np.float64) 225 | cols = np.arange(dsm.shape[1], dtype=np.float64) 226 | uu, vv = np.meshgrid(cols, rows) 227 | u: npt.NDArray = np.reshape(uu, -1) 228 | v: npt.NDArray = np.reshape(vv, -1) 229 | if area_or_point == "Area": 230 | u += 0.5 231 | v += 0.5 232 | xy = np.asarray(transform * (u, v)).T 233 | 234 | nan_mask = np.isnan(dsm) 235 | if nodata is not None: 236 | dsm[nan_mask] = nodata 237 | mask = dsm == nodata 238 | else: 239 | mask = nan_mask 240 | mask = np.reshape(mask, -1) 241 | 242 | # interpolate the residual grid for each xy 243 | res_x, res_y, res_z, res_horiz, res_3d = self._interpolate_residuals( 244 | xy[:, 0], xy[:, 1] 245 | ) 246 | 247 | res_x[mask] = nodata 248 | res_y[mask] = nodata 249 | res_z[mask] = nodata 250 | res_horiz[mask] = nodata 251 | res_3d[mask] = nodata 252 | 253 | res_x = np.reshape(res_x, dsm.shape) 254 | res_y = np.reshape(res_y, dsm.shape) 255 | res_z = np.reshape(res_z, dsm.shape) 256 | res_horiz = np.reshape(res_horiz, dsm.shape) 257 | res_3d = np.reshape(res_3d, dsm.shape) 258 | 259 | # save the interpolated data to a new TIF file. We only save to TIF 260 | # files since they are known to handle additional bands. 261 | root, _ = os.path.splitext(self.out_name) 262 | out_name_res = f"{root}_residuals.tif" 263 | 264 | profile.update(count=6, driver="GTiff") 265 | 266 | with rasterio.open(out_name_res, "w", **profile) as dst: 267 | dst.write(dsm, 1) 268 | dst.write(res_x, 2) 269 | dst.write(res_y, 3) 270 | dst.write(res_z, 4) 271 | dst.write(res_horiz, 5) 272 | dst.write(res_3d, 6) 273 | dst.set_band_description(1, "DSM") 274 | dst.set_band_description(2, "ResidualX") 275 | dst.set_band_description(3, "ResidualY") 276 | dst.set_band_description(4, "ResidualZ") 277 | dst.set_band_description(5, "ResidualHoriz") 278 | dst.set_band_description(6, "Residual3D") 279 | 280 | self.logger.info( 281 | f"ICP residuals have been computed for each registered AOI-DSM cell and saved to: {out_name_res}" 282 | ) 283 | 284 | def _apply_mesh(self) -> None: 285 | """ 286 | Applies the registration transformation to a mesh file. No attempt is 287 | made to write the coordinate reference system since mesh files typically 288 | do not store coordinate reference system information. 289 | """ 290 | mesh = trimesh.load_mesh(self.aoi_file) 291 | 292 | mesh.apply_transform(self.get_registration_transformation()) 293 | mesh.units = self.fnd_units 294 | 295 | root, ext = os.path.splitext(self.aoi_file) 296 | 297 | if ext == ".obj": 298 | base_name = os.path.basename(root) 299 | mesh.visual.material.name = base_name 300 | 301 | mesh.export(self.out_name) 302 | self.logger.info( 303 | f"Registration has been applied to AOI-MESH and saved to: {self.out_name}" 304 | ) 305 | 306 | if self.config["ICP_SAVE_RESIDUALS"]: 307 | registered_mesh = trimesh.load_mesh(self.out_name) 308 | vertices = registered_mesh.vertices 309 | x = vertices[:, 0] 310 | y = vertices[:, 1] 311 | 312 | # interpolate the residual grid for each xy 313 | res_x, res_y, res_z, res_horiz, res_3d = self._interpolate_residuals(x, y) 314 | 315 | # save the interpolated data to a new PLY file. We only save to PLY 316 | # files since they are known to handle additional vertex attributes. 317 | attributes = dict( 318 | { 319 | "ResidualX": res_x, 320 | "ResidualY": res_y, 321 | "ResidualZ": res_z, 322 | "ResidualHoriz": res_horiz, 323 | "Residual3D": res_3d, 324 | } 325 | ) 326 | registered_mesh.vertex_attributes = attributes 327 | 328 | root, _ = os.path.splitext(self.out_name) 329 | out_name_res = root + "_residuals.ply" 330 | registered_mesh.export(out_name_res) 331 | 332 | self.logger.info( 333 | f"ICP residuals have been computed for each registered AOI-MESH vertex and saved to: {out_name_res}" 334 | ) 335 | 336 | def _apply_pointcloud(self) -> None: 337 | """ 338 | Applies the registration transformation to a point cloud file. 339 | """ 340 | pipeline = pdal.Reader(self.aoi_file) 341 | pipeline |= self.get_registration_transformation() 342 | 343 | writer_kwargs = {"filename": self.out_name} 344 | if self.fnd_crs is not None: 345 | writer_kwargs["a_srs"] = self.fnd_crs.to_wkt() 346 | writer_kwargs["forward"] = "all" 347 | writer_kwargs["offset_x"] = self.config["OFFSET_X"] 348 | writer_kwargs["offset_y"] = self.config["OFFSET_Y"] 349 | writer_kwargs['offset_z'] = self.config["OFFSET_Z"] 350 | writer_kwargs["scale_x"] = self.config["SCALE_X"] 351 | writer_kwargs["scale_y"] = self.config["SCALE_Y"] 352 | writer_kwargs['scale_z'] = self.config["SCALE_Z"] 353 | pipeline |= pdal.Writer.las(**writer_kwargs) 354 | 355 | pipeline.execute() 356 | self.logger.info( 357 | f"Registration has been applied to AOI-PCLOUD and saved to: {self.out_name}" 358 | ) 359 | 360 | if self.config["ICP_SAVE_RESIDUALS"]: 361 | # open up the registered output file, read in xy's 362 | p = pdal.Reader(self.out_name).pipeline() 363 | p.execute() 364 | arrays = p.arrays 365 | array = arrays[0] 366 | x = array["X"] 367 | y = array["Y"] 368 | 369 | # interpolate the residual grid for each xy 370 | res_x, res_y, res_z, res_horiz, res_3d = self._interpolate_residuals(x, y) 371 | 372 | # save the interpolated residuals to a new LAZ file. We only save 373 | # to LAS version 1.4 files since they are known to handle additional 374 | # point dimensions (attributes) 375 | res_dtype = np.dtype( 376 | [ 377 | ("ResidualX", np.double), 378 | ("ResidualY", np.double), 379 | ("ResidualZ", np.double), 380 | ("ResidualHoriz", np.double), 381 | ("Residual3D", np.double), 382 | ] 383 | ) 384 | res_data = np.zeros(array.shape[0], dtype=res_dtype) 385 | res_data["ResidualX"] = res_x 386 | res_data["ResidualY"] = res_y 387 | res_data["ResidualZ"] = res_z 388 | res_data["ResidualHoriz"] = res_horiz 389 | res_data["Residual3D"] = res_3d 390 | 391 | original_and_res = rfn.merge_arrays((array, res_data), flatten=True) 392 | 393 | root, _ = os.path.splitext(self.out_name) 394 | out_name_res = root + "_residuals.laz" 395 | pipe = [ 396 | { 397 | "type": "writers.las", 398 | "minor_version": 4, 399 | "extra_dims": "all", 400 | "filename": out_name_res, 401 | } 402 | ] 403 | p = pdal.Pipeline( 404 | json.dumps(pipe), 405 | arrays=[ 406 | original_and_res, 407 | ], 408 | ) 409 | p.execute() 410 | 411 | self.logger.info( 412 | f"ICP residuals have been computed for each registered AOI-PCLOUD point and saved to: {out_name_res}" 413 | ) 414 | 415 | def _interpolate_residuals( 416 | self, x: npt.NDArray, y: npt.NDArray 417 | ) -> Tuple[npt.NDArray, npt.NDArray, npt.NDArray, npt.NDArray, npt.NDArray]: 418 | """ 419 | Interpolate ICP residuals at registered AOI x,y locations. The 420 | registration is solved using a gridded set of points, while the AOI 421 | x,y locations may be disorganized and/or at a different resolution 422 | than the registration grid. Therefore, we interpolate. 423 | """ 424 | # We need to scale the residual origins and vectors to the Foundation 425 | # linear unit, which the registered AOI data has been converted to as 426 | # part of the registration. Recall that the pipeline always runs in 427 | # meters, but the Foundation may have a different linear unit. 428 | meters_to_fnd = np.eye(4) * (1 / self.fnd_units_factor) 429 | meters_to_fnd[3, 3] = 1 430 | 431 | meters_res_origins = self.residual_origins 432 | meters_res_origins = np.hstack( 433 | (meters_res_origins, np.ones((meters_res_origins.shape[0], 1))) 434 | ) 435 | fnd_res_origins = (meters_to_fnd @ meters_res_origins.T).T 436 | fnd_res_origins = fnd_res_origins[:, 0:3] 437 | 438 | meters_res_vectors = self.residual_vectors 439 | meters_res_vectors = np.hstack( 440 | (meters_res_vectors, np.ones((meters_res_vectors.shape[0], 1))) 441 | ) 442 | fnd_res_vectors = (meters_to_fnd @ meters_res_vectors.T).T 443 | fnd_res_vectors = fnd_res_vectors[:, 0:3] 444 | 445 | # We want to store residual components and combined representations 446 | x_res = fnd_res_vectors[:, 0] 447 | y_res = fnd_res_vectors[:, 1] 448 | z_res = fnd_res_vectors[:, 2] 449 | horiz_res = np.sqrt(x_res**2 + y_res**2) 450 | threeD_res = np.sqrt(np.sum(fnd_res_vectors**2, axis=1)) 451 | 452 | # Nearest neighbor is faster, but a linear interpolation looks better 453 | # Replace any NaN values produced by the interpolator with an obviously 454 | # incorrect value (-9999) 455 | triFn = Triangulation(fnd_res_origins[:, 0], fnd_res_origins[:, 1]) 456 | 457 | linTriFn = LinearTriInterpolator(triFn, x_res) 458 | interp_res_x = linTriFn(x, y) 459 | interp_res_x[np.isnan(interp_res_x)] = -9999.0 460 | 461 | linTriFn = LinearTriInterpolator(triFn, y_res) 462 | interp_res_y = linTriFn(x, y) 463 | interp_res_y[np.isnan(interp_res_y)] = -9999.0 464 | 465 | linTriFn = LinearTriInterpolator(triFn, z_res) 466 | interp_res_z = linTriFn(x, y) 467 | interp_res_z[np.isnan(interp_res_z)] = -9999.0 468 | 469 | linTriFn = LinearTriInterpolator(triFn, horiz_res) 470 | interp_res_horiz = linTriFn(x, y) 471 | interp_res_horiz[np.isnan(interp_res_horiz)] = -9999.0 472 | 473 | linTriFn = LinearTriInterpolator(triFn, threeD_res) 474 | interp_res_3d = linTriFn(x, y) 475 | interp_res_3d[np.isnan(interp_res_3d)] = -9999.0 476 | 477 | return interp_res_x, interp_res_y, interp_res_z, interp_res_horiz, interp_res_3d 478 | -------------------------------------------------------------------------------- /src/codem/registration/dsm.py: -------------------------------------------------------------------------------- 1 | """ 2 | DsmRegistration.py 3 | Project: CRREL-NEGGS University of Houston Collaboration 4 | Date: February 2021 5 | 6 | This module contains classes to co-register pre-processed DSM data. Features are 7 | extracted from two DSMs, matched, and the registration transformation solved 8 | from the matched features. 9 | 10 | This module contains the following classes: 11 | 12 | * DsmRegistration: a class for DSM to DSM registration 13 | * Unscaled3dSimilarityTransform: a class for solving the 6-parameter 14 | transformation (no scale factor) between two sets of 3D points. 15 | * Scaled3dSimilarityTransform: a class for solving the 7-parameter 16 | transformation (includes a scale factor) between two sets of 3D points. 17 | """ 18 | import logging 19 | import math 20 | import os 21 | import warnings 22 | from typing import List 23 | from typing import Tuple 24 | 25 | import cv2 26 | import numpy as np 27 | import numpy.typing as npt 28 | from codem.preprocessing.preprocess import CodemParameters 29 | from codem.preprocessing.preprocess import GeoData 30 | from codem.preprocessing.preprocess import RegistrationParameters 31 | from rasterio import Affine 32 | from skimage.measure import ransac 33 | 34 | 35 | class DsmRegistration: 36 | """ 37 | A class to solve the transformation between two Digital Surface Models. 38 | Uses a feature matching approach. 39 | 40 | Parameters 41 | ---------- 42 | fnd_obj: DSM object 43 | the foundation DSM 44 | aoi_obj: DSM object 45 | the area of interest DSM 46 | config: CodemParameters 47 | dictionary of configuration parameters 48 | 49 | Methods 50 | -------- 51 | register 52 | _get_kp 53 | _get_putative 54 | _filter_putative 55 | _save_match_img 56 | _get_geo_coords 57 | _get_rmse 58 | _output 59 | """ 60 | 61 | def __init__( 62 | self, fnd_obj: GeoData, aoi_obj: GeoData, config: CodemParameters 63 | ) -> None: 64 | self.logger = logging.getLogger(__name__) 65 | self.config = config 66 | self.fnd_obj = fnd_obj 67 | self.aoi_obj = aoi_obj 68 | self._putative_matches: List[cv2.DMatch] = [] 69 | 70 | if not aoi_obj.processed: 71 | raise RuntimeError( 72 | "AOI data has not been pre-processed, did you call the prep method?" 73 | ) 74 | if not fnd_obj.processed: 75 | raise RuntimeError( 76 | "Foundation data has not been pre-processed, did you call the prep method?" 77 | ) 78 | 79 | @property 80 | def putative_matches(self) -> List[cv2.DMatch]: 81 | return self._putative_matches 82 | 83 | @putative_matches.setter 84 | def putative_matches(self, matches: List[cv2.DMatch]) -> None: 85 | if len(matches) < 4: 86 | raise RuntimeError( 87 | ( 88 | f"{len(matches)} putative keypoint matches found (4 required). " 89 | "Consider modifying DSM_LOWES_RATIO, current value: " 90 | f"{self.config['DSM_LOWES_RATIO']}" 91 | ) 92 | ) 93 | self._putative_matches = matches 94 | 95 | def register(self) -> None: 96 | """ 97 | Performs DSM co-registration via the following steps: 98 | * Extract features from foundation and AOI DSMs 99 | * Generate putative matches via nearest neighbor in descriptor space 100 | * Filter putative matches for those that conform to a common 101 | transformation 102 | 103 | After registration, RMSEs of matched features are computed and 104 | transformation details added as a class attribute. 105 | """ 106 | self.logger.info("Solving DSM feature registration.") 107 | 108 | self.fnd_kp, self.fnd_desc = self._get_kp( 109 | self.fnd_obj.normed, self.fnd_obj.nodata_mask 110 | ) 111 | self.logger.debug(f"{len(self.fnd_kp)} keypoints detected in foundation") 112 | if len(self.fnd_kp) < 4: 113 | raise RuntimeError( 114 | ( 115 | f"{len(self.fnd_kp)} keypoints were identified in the Foundation data" 116 | " (4 required). Consider modifying the DSM_AKAZE_THRESHOLD parameter," 117 | f" current value is {self.config['DSM_AKAZE_THRESHOLD']}" 118 | ) 119 | ) 120 | 121 | self.aoi_kp, self.aoi_desc = self._get_kp( 122 | self.aoi_obj.normed, self.aoi_obj.nodata_mask 123 | ) 124 | self.logger.debug(f"{len(self.aoi_kp)} keypoints detected in area of interest") 125 | if len(self.aoi_kp) < 4: 126 | raise RuntimeError( 127 | ( 128 | f"{len(self.aoi_kp)} keypoints were identified in the " 129 | "AOI data (4 required). Consider modifying the DSM_AKAZE_THRESHOLD" 130 | f"parameter, current value is {self.config['DSM_AKAZE_THRESHOLD']}" 131 | ) 132 | ) 133 | self._get_putative() 134 | self._filter_putative() 135 | self._save_match_img() 136 | 137 | self._get_rmse() 138 | self._output() 139 | 140 | def _get_kp( 141 | self, img: np.ndarray, mask: np.ndarray 142 | ) -> Tuple[Tuple[cv2.KeyPoint, ...], np.ndarray]: 143 | """ 144 | Extracts AKAZE features, in the form of keypoints and descriptors, 145 | from an 8-bit grayscale image. 146 | 147 | Parameters 148 | ---------- 149 | img: np.array 150 | Normalized 8-bit grayscale image 151 | mask: np.array 152 | Mask of valid locations for img feature extraction 153 | 154 | Returns 155 | ---------- 156 | kp: tuple(cv2.KeyPoint,...) 157 | OpenCV keypoints 158 | desc: np.array 159 | OpenCV AKAZE descriptors 160 | """ 161 | detector = cv2.AKAZE_create(threshold=self.config["DSM_AKAZE_THRESHOLD"]) 162 | kp, desc = detector.detectAndCompute(img, np.ones(mask.shape, dtype=np.uint8)) 163 | return kp, desc 164 | 165 | def _get_putative(self) -> None: 166 | """ 167 | Identifies putative matches for DSM co-registration via a nearest 168 | neighbor search in descriptor space. When very large numbers of 169 | descriptors exist, an approximate matching method is used to prevent 170 | failure. References regarding this problem: 171 | * https://answers.opencv.org/question/85003/why-there-is-a-hardcoded-maximum-numbers-of-descriptors-that-can-be-matched-with-bfmatcher/ 172 | * https://docs.opencv.org/master/dc/dc3/tutorial_py_matcher.html 173 | * https://luckytaylor.top/modules/flann/doc/flann_fast_approximate_nearest_neighbor_search.html 174 | """ 175 | if self.aoi_desc.shape[0] > 2**17 or self.fnd_desc.shape[0] > 2**17: 176 | FLANN_INDEX_LSH = 6 177 | index_params = dict( 178 | algorithm=FLANN_INDEX_LSH, 179 | table_number=6, # 12 180 | key_size=12, # 20 181 | multi_probe_level=1, # 2 182 | ) 183 | desc_matcher = cv2.FlannBasedMatcher(index_params, {}) 184 | else: 185 | desc_matcher = cv2.DescriptorMatcher_create( 186 | cv2.DescriptorMatcher_BRUTEFORCE_HAMMING 187 | ) 188 | 189 | # Fnd = train; AOI = query; knnMatch parameter order is query, train 190 | knn_matches = desc_matcher.knnMatch(self.aoi_desc, self.fnd_desc, k=2) 191 | 192 | # Lowe's ratio test to filter weak matches 193 | good_matches = [ 194 | m 195 | for m, n in knn_matches 196 | if m.distance < self.config["DSM_LOWES_RATIO"] * n.distance 197 | ] 198 | 199 | self.logger.debug(f"{len(good_matches)} putative keypoint matches found.") 200 | self.putative_matches = good_matches 201 | 202 | def _filter_putative(self) -> None: 203 | """ 204 | Filters putative matches via conformance to a 3D similarity transform. 205 | Note that the fnd_uv and aoi_uv coordinates are relative to OpenCV's 206 | AKAZE feature detection origin at the center of the upper left pixel. 207 | https://github.com/opencv/opencv/commit/e646f9d2f1b276991a59edf01bc87dcdf28e2b8f 208 | """ 209 | # Get 2D image space coords of putative keypoint matches 210 | fnd_uv = np.array( 211 | [self.fnd_kp[m.trainIdx].pt for m in self.putative_matches], 212 | dtype=np.float32, 213 | ) 214 | aoi_uv = np.array( 215 | [self.aoi_kp[m.queryIdx].pt for m in self.putative_matches], 216 | dtype=np.float32, 217 | ) 218 | 219 | # Get 3D object space coords of putative keypoint matches 220 | fnd_xyz = self._get_geo_coords( 221 | fnd_uv, 222 | self.fnd_obj.transform, 223 | self.fnd_obj.area_or_point, 224 | self.fnd_obj.infilled, 225 | ) 226 | aoi_xyz = self._get_geo_coords( 227 | aoi_uv, 228 | self.aoi_obj.transform, 229 | self.aoi_obj.area_or_point, 230 | self.aoi_obj.infilled, 231 | ) 232 | # Find 3D similarity transform conforming to max number of matches 233 | if self.config["DSM_SOLVE_SCALE"]: 234 | model, inliers = ransac( 235 | (aoi_xyz, fnd_xyz), 236 | Scaled3dSimilarityTransform, 237 | min_samples=3, 238 | residual_threshold=self.config["DSM_RANSAC_THRESHOLD"], 239 | max_trials=self.config["DSM_RANSAC_MAX_ITER"], 240 | ) 241 | else: 242 | model, inliers = ransac( 243 | (aoi_xyz, fnd_xyz), 244 | Unscaled3dSimilarityTransform, 245 | min_samples=3, 246 | residual_threshold=self.config["DSM_RANSAC_THRESHOLD"], 247 | max_trials=self.config["DSM_RANSAC_MAX_ITER"], 248 | ) 249 | if model is None: 250 | raise ValueError( 251 | "ransac model not fitted, no inliers found. Consider tuning " 252 | "DSM_RANSAC_THRESHOLD or DSM_RANSAC_MAX_ITER" 253 | ) 254 | self.logger.info(f"{np.sum(inliers)} keypoint matches found.") 255 | 256 | if np.sum(inliers) < 4: 257 | raise RuntimeError("Less than 4 keypoint matches found.") 258 | 259 | T: np.ndarray = model.transform 260 | c = np.linalg.norm(T[:, 0]) 261 | if c < 0.67 or c > 1.5: 262 | warnings.warn( 263 | ( 264 | "Coarse registration solved scale between datasets exceeds 50%. " 265 | "Registration is likely to fail" 266 | ), 267 | category=RuntimeWarning, 268 | stacklevel=2, 269 | ) 270 | 271 | self.transformation = T 272 | self.inliers = inliers 273 | self.fnd_inliers_xyz = fnd_xyz[inliers] 274 | self.aoi_inliers_xyz = aoi_xyz[inliers] 275 | 276 | def _save_match_img(self) -> None: 277 | """ 278 | Save image of matched features with connecting lines on the 279 | foundation and AOI DSM images. The outline of the transformed AOI 280 | boundary is also plotted on the foundation DSM image. 281 | """ 282 | if self.fnd_obj.transform is None: 283 | raise RuntimeError( 284 | "Foundation Object transform has not been set, did you run the prep() method?" 285 | ) 286 | h, w = self.aoi_obj.normed.shape 287 | aoi_uv = np.array( 288 | [[0, 0], [0, h - 1], [w - 1, h - 1], [w - 1, 0]], dtype=np.float32 289 | ) 290 | 291 | aoi_xyz = self._get_geo_coords( 292 | aoi_uv, 293 | self.aoi_obj.transform, 294 | self.aoi_obj.area_or_point, 295 | self.aoi_obj.infilled, 296 | ) 297 | aoi_xyz = np.vstack((aoi_xyz.T, np.ones((1, 4)))).T 298 | 299 | fnd_xyz = (self.transformation @ aoi_xyz.T).T 300 | fnd_xy = np.vstack((fnd_xyz[:, 0:2].T, np.ones((1, 4)))).T 301 | 302 | F_inverse: npt.NDArray[np.float64] = np.reshape(np.asarray(~self.fnd_obj.transform), (3, 3)) 303 | fnd_uv = (F_inverse @ fnd_xy.T).T 304 | fnd_uv = fnd_uv[:, 0:2] 305 | 306 | # Draw AOI outline onto foundation DSM 307 | fnd_prep = self.fnd_obj.normed.copy() 308 | fnd_prep = cv2.polylines( 309 | fnd_prep, 310 | np.expand_dims(fnd_uv, axis=0).astype(np.int32), 311 | isClosed=True, 312 | color=(255, 0, 0), 313 | thickness=2, 314 | lineType=cv2.LINE_AA, 315 | ) 316 | 317 | # Draw keypoints and match lines onto AOI and foundation DSMs 318 | kp_lines = cv2.drawMatches( 319 | self.aoi_obj.normed, 320 | self.aoi_kp, 321 | fnd_prep, 322 | self.fnd_kp, 323 | self.putative_matches, 324 | None, 325 | matchColor=(0, 255, 0), 326 | singlePointColor=None, 327 | matchesMask=self.inliers.astype(int).tolist(), 328 | flags=cv2.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS, 329 | ) 330 | 331 | output_file = os.path.join(self.config["OUTPUT_DIR"], "dsm_feature_matches.png") 332 | self.logger.info(f"Saving DSM feature match visualization to: {output_file}") 333 | cv2.imwrite(output_file, kp_lines) 334 | 335 | def _get_geo_coords( 336 | self, uv: np.ndarray, transform: Affine, area_or_point: str, dsm: np.ndarray 337 | ) -> np.ndarray: 338 | """ 339 | Converts image space coordinates to object space coordinates. The passed 340 | uv coordinates must be relative to an origin at the center of the upper 341 | left pixel. These coordinates are correct for querying the DSM array for 342 | elevations. However, they need to be adjusted by 0.5 pixel when 343 | generating horizontal coordinates if the DSM transform is defined with 344 | an origin at the upper left of the upper left pixel. 345 | 346 | Parameters 347 | ---------- 348 | uv: np.array 349 | Image space coordinates relative to an origin at the center of the 350 | upper left pixel (u=column, v=row) 351 | transform: affine.Affine 352 | Image space to object space 2D homogenous transformation matrix 353 | area_or_point: str 354 | "Area" or "Point" string indicating if the transform assumes the 355 | coordinate origin is at the upper left of the upper left pixel or at 356 | the center of the upper left pixel 357 | dsm: np.array 358 | The DSM elevation data 359 | 360 | Returns 361 | ------- 362 | xyz: np.array 363 | Geospatial coordinates 364 | """ 365 | z = [] 366 | for cr in uv: 367 | cr_int = np.rint(cr).astype(np.int32) 368 | z.append(dsm[cr_int[1], cr_int[0]]) 369 | 370 | # When using the transform to convert from pixels to object space 371 | # coordinates, we need to increment the pixel coordinates by 0.5 if the 372 | # transform origin is defined at the upper left corner of the upper 373 | # left pixel 374 | if area_or_point == "Area": 375 | uv += 0.5 376 | xy = [] 377 | for cr in uv: 378 | temp = transform * (cr) 379 | xy.append([temp[0], temp[1]]) 380 | 381 | z_ = np.asarray(z, dtype=np.float64) 382 | xy_ = np.asarray(xy, dtype=np.float64) 383 | result: np.ndarray = np.vstack((xy_.T, z_)).T 384 | return result 385 | 386 | def _get_rmse(self) -> None: 387 | """ 388 | Calculates root mean squared error between the geospatial locations of 389 | the matched foundation and aoi features used in the registration. 390 | """ 391 | fnd_xyz = self.fnd_inliers_xyz 392 | aoi_xyz = self.aoi_inliers_xyz 393 | X = self.transformation 394 | 395 | aoi_xyz = np.hstack((aoi_xyz, np.ones((aoi_xyz.shape[0], 1)))) 396 | aoi_xyz_transformed = (X @ aoi_xyz.T).T 397 | aoi_xyz_transformed = aoi_xyz_transformed[:, 0:3] 398 | 399 | self.rmse_xyz = np.sqrt( 400 | np.sum((fnd_xyz - aoi_xyz_transformed) ** 2, axis=0) / fnd_xyz.shape[0] 401 | ) 402 | self.rmse_3d = np.sqrt(np.sum(self.rmse_xyz**2)) 403 | 404 | def _output(self) -> None: 405 | """ 406 | Stores registration results in a dictionary and writes them to a file 407 | """ 408 | X: npt.NDArray[np.float64] = self.transformation 409 | R = X[0:3, 0:3] 410 | c = np.sqrt(R[0, 0] ** 2 + R[1, 0] ** 2 + R[2, 0] ** 2) 411 | omega = np.rad2deg(np.arctan2(R[2, 1] / c, R[2, 2] / c)) 412 | phi = np.rad2deg(-np.arcsin(R[2, 0] / c)) 413 | kappa = np.rad2deg(np.arctan2(R[1, 0] / c, R[0, 0] / c)) 414 | tx = X[0, 3] 415 | ty = X[1, 3] 416 | tz = X[2, 3] 417 | 418 | self.registration_parameters: RegistrationParameters = { 419 | "matrix": X, 420 | "omega": omega, 421 | "phi": phi, 422 | "kappa": kappa, 423 | "trans_x": tx, 424 | "trans_y": ty, 425 | "trans_z": tz, 426 | "scale": c, 427 | "n_pairs": self.fnd_inliers_xyz.shape[0], 428 | "rmse_x": self.rmse_xyz[0], 429 | "rmse_y": self.rmse_xyz[1], 430 | "rmse_z": self.rmse_xyz[2], 431 | "rmse_3d": self.rmse_3d, 432 | } 433 | 434 | output_file = os.path.join(self.config["OUTPUT_DIR"], "registration.txt") 435 | 436 | self.logger.info( 437 | f"Saving DSM feature registration parameters to: {output_file}" 438 | ) 439 | with open(output_file, "w", encoding="utf_8") as f: 440 | f.write("DSM FEATURE REGISTRATION") 441 | f.write("\n------------------------") 442 | f.write(f"\nTransformation matrix: \n {X}") 443 | f.write("\nTransformation Parameters:") 444 | f.write(f"\nOmega = {omega:.3f} degrees") 445 | f.write(f"\nPhi = {phi:.3f} degrees") 446 | f.write(f"\nKappa = {kappa:.3f} degrees") 447 | f.write(f"\nX Translation = {tx:.3f}") 448 | f.write(f"\nY Translation = {ty:.3f}") 449 | f.write(f"\nZ Translation = {tz:.3f}") 450 | f.write(f"\nScale = {c:.6f}") 451 | f.write(f"\nNumber of pairs = {self.registration_parameters['n_pairs']}") 452 | f.write("\nRMSEs:") 453 | f.write( 454 | f"\nX = +/-{self.rmse_xyz[0]:.3f}," 455 | f"\nY = +/-{self.rmse_xyz[1]:.3f}," 456 | f"\nZ = +/-{self.rmse_xyz[2]:.3f}," 457 | f"\n3D = +/-{self.rmse_3d:.3f}" 458 | ) 459 | f.write("\nPropagated Error:") 460 | f.write( 461 | "\nAssumed global 3D error = +/-3m" 462 | f"\n3D_RMSE = +/-{self.rmse_3d:.3f}" 463 | "\nTotal Error is computed as √(global_3d_error²+3D_RMSE²)" 464 | f"\nTotal Error = +/-{math.hypot(3, self.rmse_3d):.3f}" 465 | ) 466 | f.write("\n\n") 467 | 468 | 469 | class Scaled3dSimilarityTransform: 470 | """ 471 | A class for solving a 3D similarity transformation between two sets of 3D 472 | points. For use with scikit-image's RANSAC routing. The _umeyama method is 473 | from: https://github.com/scikit-image/scikit-image/blob/main/skimage/transform/_geometric.py 474 | """ 475 | 476 | def __init__(self) -> None: 477 | self.solve_scale = True 478 | 479 | def estimate(self, src: np.ndarray, dst: np.ndarray) -> bool: 480 | """ 481 | Function to estimate the least squares transformation between the source 482 | (src) and destination (dst) 3D point pairs. 483 | 484 | Parameters 485 | ---------- 486 | src: np.array 487 | Array of points to be transformed to the fixed dst point locations 488 | dst: np.array 489 | Array of fixed points ordered to correspond to the src points 490 | 491 | Returns 492 | ------- 493 | success: bool 494 | True if transformation was able to be solved 495 | """ 496 | 497 | self.transform = self._umeyama(src, dst, self.solve_scale) 498 | return True 499 | 500 | def residuals(self, src: np.ndarray, dst: np.ndarray) -> np.ndarray: 501 | """ 502 | Function to compute residual distance between each point pair after 503 | registration 504 | 505 | Parameters 506 | ---------- 507 | src: np.array 508 | Array of points transformed to the fixed dst point locations 509 | dst: np.array 510 | Array of fixed points ordered to correspond to the src points 511 | 512 | Returns 513 | ------- 514 | residuals: np.array 515 | Residual for each point pair 516 | """ 517 | src = np.hstack((src, np.ones((src.shape[0], 1)))) 518 | src_transformed = (self.transform @ src.T).T 519 | src_transformed = src_transformed[:, 0:3] 520 | residuals: np.ndarray = np.sqrt(np.sum((src_transformed - dst) ** 2, axis=1)) 521 | return residuals 522 | 523 | def _umeyama( 524 | self, src: np.ndarray, dst: np.ndarray, estimate_scale: bool 525 | ) -> np.ndarray: 526 | """ 527 | Estimate N-D similarity transformation with or without scaling. 528 | 529 | Parameters 530 | ---------- 531 | src : (M, N) array 532 | Source coordinates. 533 | dst : (M, N) array 534 | Destination coordinates. 535 | estimate_scale : bool 536 | Whether to estimate scaling factor. 537 | 538 | Returns 539 | ------- 540 | T : (N + 1, N + 1) 541 | The homogeneous similarity transformation matrix. The matrix contains 542 | NaN values only if the problem is not well-conditioned. 543 | 544 | References 545 | ---------- 546 | .. [1] "Least-squares estimation of transformation parameters between two 547 | point patterns", Shinji Umeyama, PAMI 1991, :DOI:`10.1109/34.88573` 548 | """ 549 | 550 | num = src.shape[0] 551 | dim = src.shape[1] 552 | 553 | # Compute mean of src and dst. 554 | src_mean = src.mean(axis=0) 555 | dst_mean = dst.mean(axis=0) 556 | 557 | # Subtract mean from src and dst. 558 | src_demean = src - src_mean 559 | dst_demean = dst - dst_mean 560 | 561 | # Eq. (38). 562 | A = dst_demean.T @ src_demean / num 563 | 564 | # Eq. (39). 565 | d = np.ones((dim,), dtype=np.double) 566 | if np.linalg.det(A) < 0: 567 | d[dim - 1] = -1 568 | 569 | T: np.ndarray = np.eye(dim + 1, dtype=np.double) 570 | 571 | U, S, V = np.linalg.svd(A) 572 | 573 | # Eq. (40) and (43). 574 | rank = np.linalg.matrix_rank(A) 575 | if rank == 0: 576 | result: np.ndarray = np.full_like(T, np.nan) 577 | return result 578 | elif rank == dim - 1: 579 | if np.linalg.det(U) * np.linalg.det(V) > 0: 580 | T[:dim, :dim] = U @ V 581 | else: 582 | s = d[dim - 1] 583 | d[dim - 1] = -1 584 | T[:dim, :dim] = U @ np.diag(d) @ V 585 | d[dim - 1] = s 586 | else: 587 | T[:dim, :dim] = U @ np.diag(d) @ V 588 | 589 | scale = 1.0 / src_demean.var(axis=0).sum() * (S @ d) if estimate_scale else 1.0 590 | T[:dim, dim] = dst_mean - scale * (T[:dim, :dim] @ src_mean.T) 591 | T[:dim, :dim] *= scale 592 | return T 593 | 594 | 595 | class Unscaled3dSimilarityTransform: 596 | """ 597 | A class for solving a 3D similarity transformation between two sets of 3D 598 | points. For use with scikit-image's RANSAC routing. The _umeyama method is 599 | from: https://github.com/scikit-image/scikit-image/blob/main/skimage/transform/_geometric.py 600 | """ 601 | 602 | def __init__(self) -> None: 603 | self.solve_scale = False 604 | 605 | def estimate(self, src: np.ndarray, dst: np.ndarray) -> bool: 606 | """ 607 | Function to estimate the least squares transformation between the source 608 | (src) and destination (dst) 3D point pairs. 609 | 610 | Parameters 611 | ---------- 612 | src: np.array 613 | Array of points to be transformed to the fixed dst point locations 614 | dst: np.array 615 | Array of fixed points ordered to correspond to the src points 616 | 617 | Returns 618 | ------- 619 | success: bool 620 | True if transformation was able to be solved 621 | """ 622 | 623 | self.transform = self._umeyama(src, dst, self.solve_scale) 624 | return True 625 | 626 | def residuals(self, src: np.ndarray, dst: np.ndarray) -> np.ndarray: 627 | """ 628 | Function to compute residual distance between each point pair after 629 | registration 630 | 631 | Parameters 632 | ---------- 633 | src: np.array 634 | Array of points transformed to the fixed dst point locations 635 | dst: np.array 636 | Array of fixed points ordered to correspond to the src points 637 | 638 | Returns 639 | ------- 640 | residuals: np.array 641 | Residual for each point pair 642 | """ 643 | src = np.hstack((src, np.ones((src.shape[0], 1)))) 644 | src_transformed = (self.transform @ src.T).T 645 | src_transformed = src_transformed[:, 0:3] 646 | residuals: np.ndarray = np.sqrt(np.sum((src_transformed - dst) ** 2, axis=1)) 647 | return residuals 648 | 649 | def _umeyama( 650 | self, src: np.ndarray, dst: np.ndarray, estimate_scale: bool 651 | ) -> np.ndarray: 652 | """ 653 | Estimate N-D similarity transformation with or without scaling. 654 | 655 | Parameters 656 | ---------- 657 | src : (M, N) array 658 | Source coordinates. 659 | dst : (M, N) array 660 | Destination coordinates. 661 | estimate_scale : bool 662 | Whether to estimate scaling factor. 663 | 664 | Returns 665 | ------- 666 | T : (N + 1, N + 1) 667 | The homogeneous similarity transformation matrix. The matrix contains 668 | NaN values only if the problem is not well-conditioned. 669 | 670 | References 671 | ---------- 672 | .. [1] "Least-squares estimation of transformation parameters between two 673 | point patterns", Shinji Umeyama, PAMI 1991, :DOI:`10.1109/34.88573` 674 | """ 675 | 676 | num = src.shape[0] 677 | dim = src.shape[1] 678 | 679 | # Compute mean of src and dst. 680 | src_mean = src.mean(axis=0) 681 | dst_mean = dst.mean(axis=0) 682 | 683 | # Subtract mean from src and dst. 684 | src_demean = src - src_mean 685 | dst_demean = dst - dst_mean 686 | 687 | # Eq. (38). 688 | A = dst_demean.T @ src_demean / num 689 | 690 | # Eq. (39). 691 | d = np.ones((dim,), dtype=np.double) 692 | if np.linalg.det(A) < 0: 693 | d[dim - 1] = -1 694 | 695 | T: np.ndarray = np.eye(dim + 1, dtype=np.double) 696 | 697 | U, S, V = np.linalg.svd(A) 698 | 699 | # Eq. (40) and (43). 700 | rank = np.linalg.matrix_rank(A) 701 | if rank == 0: 702 | result: np.ndarray = np.full_like(T, np.nan) 703 | return result 704 | elif rank == dim - 1: 705 | if np.linalg.det(U) * np.linalg.det(V) > 0: 706 | T[:dim, :dim] = U @ V 707 | else: 708 | s = d[dim - 1] 709 | d[dim - 1] = -1 710 | T[:dim, :dim] = U @ np.diag(d) @ V 711 | d[dim - 1] = s 712 | else: 713 | T[:dim, :dim] = U @ np.diag(d) @ V 714 | 715 | scale = 1.0 / src_demean.var(axis=0).sum() * (S @ d) if estimate_scale else 1.0 716 | T[:dim, dim] = dst_mean - scale * (T[:dim, :dim] @ src_mean.T) 717 | T[:dim, :dim] *= scale 718 | 719 | return T 720 | -------------------------------------------------------------------------------- /src/codem/registration/icp.py: -------------------------------------------------------------------------------- 1 | """ 2 | IcpRegistration.py 3 | Project: CRREL-NEGGS University of Houston Collaboration 4 | Date: February 2021 5 | 6 | This module contains a class to co-register two point clouds using a robust 7 | point-to-plane ICP method. 8 | 9 | This module contains the following class: 10 | 11 | * IcpRegistration: a class for point cloud to point cloud registration 12 | """ 13 | from __future__ import annotations 14 | 15 | import logging 16 | import math 17 | import os 18 | import warnings 19 | from typing import Any 20 | from typing import Dict 21 | from typing import Tuple 22 | from typing import TYPE_CHECKING 23 | 24 | import numpy as np 25 | from codem.preprocessing.preprocess import CodemParameters 26 | from codem.preprocessing.preprocess import GeoData 27 | from codem.preprocessing.preprocess import RegistrationParameters 28 | from scipy import spatial 29 | from scipy.sparse import diags 30 | 31 | if TYPE_CHECKING: 32 | from codem.registration import DsmRegistration 33 | 34 | 35 | class IcpRegistration: 36 | """ 37 | A class to solve the transformation between two point clouds. Uses point-to- 38 | plane ICP with robust weighting. 39 | 40 | Parameters 41 | ---------- 42 | fnd_obj: DSM object 43 | the foundation DSM 44 | aoi_obj: DSM object 45 | the area of interest DSM 46 | dsm_reg: DsmRegistration object 47 | object holding the dsm registration data 48 | config: Dictionary 49 | dictionary of configuration parameters 50 | 51 | Methods 52 | -------- 53 | register 54 | _residuals 55 | _get_weights 56 | _apply_transform 57 | _scaled 58 | _unscaled 59 | _output 60 | """ 61 | 62 | def __init__( 63 | self, 64 | fnd_obj: GeoData, 65 | aoi_obj: GeoData, 66 | dsm_reg: DsmRegistration, 67 | config: CodemParameters, 68 | ) -> None: 69 | self.logger = logging.getLogger(__name__) 70 | self.fixed = fnd_obj.point_cloud 71 | self.normals = fnd_obj.normal_vectors 72 | self.moving = aoi_obj.point_cloud 73 | self.resolution = aoi_obj.resolution 74 | self.initial_transform = dsm_reg.registration_parameters["matrix"] 75 | self.outlier_thresh = dsm_reg.registration_parameters["rmse_3d"] 76 | self.config = config 77 | self.residual_origins: np.ndarray = np.empty((0, 0), np.double) 78 | self.residual_vectors: np.ndarray = np.empty((0, 0), np.double) 79 | 80 | if not all( 81 | [ 82 | self.fixed.shape[1] == 3, 83 | self.moving.shape[1] == 3, 84 | self.normals.shape[1] == 3, 85 | ] 86 | ): 87 | raise ValueError("Point and Normal Vector Must be 3D") 88 | 89 | if self.fixed.shape[0] < 7 or self.moving.shape[0] < 7: 90 | raise ValueError( 91 | "At least 7 points required for hte point to plane ICP algorithm." 92 | ) 93 | 94 | if self.normals.shape != self.fixed.shape: 95 | raise ValueError( 96 | "Normal vector array must be same size as fixed points array." 97 | ) 98 | 99 | def register(self) -> None: 100 | """ 101 | Executes ICP by minimizing point-to-plane distances: 102 | * Find fixed point closest to each moving point 103 | * Find the transform that minimizes the projected distance between each 104 | paired fixed and moving point, where the projection is to the fixed 105 | point normal direction 106 | * Apply the transform to the moving points 107 | * Repeat above steps until a convergence criteria or maximum iteration 108 | threshold is reached 109 | * Assign final transformation as attribute 110 | """ 111 | self.logger.info("Solving ICP registration.") 112 | 113 | # Apply transform from previous feature-matching registration 114 | moving = self._apply_transform(self.moving, self.initial_transform) 115 | 116 | # Remove fixed mean to decorrelate rotation and translation 117 | fixed_mean = np.mean(self.fixed, axis=0) 118 | fixed = self.fixed - fixed_mean 119 | moving = moving - fixed_mean 120 | 121 | fixed_tree = spatial.KDTree(fixed) 122 | 123 | cumulative_transform = np.eye(4) 124 | moving_transformed = moving 125 | rmse = np.float64(0.0) 126 | previous_rmse = np.float64(1e-12) 127 | 128 | alpha = 2.0 129 | beta = (self.resolution) / 2 + 0.5 130 | tau = 0.2 131 | 132 | for i in range(self.config["ICP_MAX_ITER"]): 133 | _, idx = fixed_tree.query( 134 | moving_transformed, k=1, distance_upper_bound=self.outlier_thresh 135 | ) 136 | include_fixed = idx[idx < fixed.shape[0]] 137 | include_moving = idx < fixed.shape[0] 138 | temp_fixed = fixed[include_fixed] 139 | temp_normals = self.normals[include_fixed] 140 | temp_moving_transformed = moving_transformed[include_moving] 141 | 142 | if temp_fixed.shape[0] < 7: 143 | raise RuntimeError( 144 | "At least 7 points within the ICP outlier threshold are required (" 145 | f"{temp_fixed.shape[0]} detected)." 146 | ) 147 | 148 | weights = self._get_weights( 149 | temp_fixed, temp_normals, temp_moving_transformed, alpha, beta 150 | ) 151 | alpha -= tau 152 | 153 | if self.config["ICP_SOLVE_SCALE"]: 154 | current_transform, euler, distance = self._scaled( 155 | temp_fixed, temp_normals, temp_moving_transformed, weights 156 | ) 157 | else: 158 | current_transform, euler, distance = self._unscaled( 159 | temp_fixed, temp_normals, temp_moving_transformed, weights 160 | ) 161 | 162 | cumulative_transform = current_transform @ cumulative_transform 163 | moving_transformed = self._apply_transform(moving, cumulative_transform) 164 | 165 | temp_moving_transformed = self._apply_transform( 166 | temp_moving_transformed, current_transform 167 | ) 168 | squared_error = (temp_fixed - temp_moving_transformed) ** 2 169 | rmse = np.sqrt( 170 | np.sum(np.sum(squared_error, axis=1)) / temp_moving_transformed.shape[0] 171 | ) 172 | 173 | relative_change_rmse = np.abs((rmse - previous_rmse) / previous_rmse) 174 | previous_rmse = rmse 175 | 176 | if relative_change_rmse < self.config["ICP_RMSE_THRESHOLD"]: 177 | self.logger.debug("ICP converged via minimum relative change in RMSE.") 178 | break 179 | 180 | if ( 181 | euler < self.config["ICP_ANGLE_THRESHOLD"] 182 | and distance < self.config["ICP_DISTANCE_THRESHOLD"] 183 | ): 184 | self.logger.debug("ICP converged via angle and distance thresholds.") 185 | break 186 | 187 | self.rmse_3d = rmse 188 | self.rmse_xyz = np.sqrt( 189 | np.sum((temp_fixed - temp_moving_transformed) ** 2, axis=0) 190 | / temp_moving_transformed.shape[0] 191 | ) 192 | self.number_points = temp_moving_transformed.shape[0] 193 | self.logger.debug(f"ICP number of iterations = {i+1}, RMSE = {rmse}") 194 | 195 | # The mean removal must be accommodated to generate the actual transform 196 | pre_transform = np.eye(4) 197 | pre_transform[:3, 3] = -fixed_mean 198 | post_transform = np.eye(4) 199 | post_transform[:3, 3] = fixed_mean 200 | icp_transform = post_transform @ cumulative_transform @ pre_transform 201 | 202 | T = icp_transform @ self.initial_transform 203 | c = np.sqrt(T[0, 0] ** 2 + T[1, 0] ** 2 + T[2, 0] ** 2) 204 | if c < 0.67 or c > 1.5: 205 | warnings.warn( 206 | ( 207 | "Coarse regsistration solved scale between datasets exceeds 50%. " 208 | "Registration is likely to fail" 209 | ), 210 | category=RuntimeWarning, 211 | stacklevel=2, 212 | ) 213 | if self.config["ICP_SAVE_RESIDUALS"]: 214 | self.residual_origins = self._apply_transform(self.moving, T) 215 | self.residual_vectors = self._residuals( 216 | fixed_tree, fixed, self.normals, moving_transformed 217 | ) 218 | 219 | self.transformation = T 220 | self._output() 221 | 222 | def _residuals( 223 | self, 224 | fixed_tree: spatial.cKDTree, 225 | fixed: np.ndarray, 226 | normals: np.ndarray, 227 | moving: np.ndarray, 228 | ) -> np.ndarray: 229 | """ 230 | Generates residual vectors for visualization purposes to illustrate the 231 | approximate orthogonal difference between the foundation and AOI 232 | surfaces that remains after registration. Note that these residuals will 233 | always be in meters. 234 | """ 235 | _, idx = fixed_tree.query(moving, k=1) 236 | include_fixed = idx[idx < fixed.shape[0]] 237 | include_moving = idx < fixed.shape[0] 238 | temp_fixed = fixed[include_fixed] 239 | temp_normals = normals[include_fixed] 240 | temp_moving = moving[include_moving] 241 | 242 | residuals = np.sum((temp_moving - temp_fixed) * temp_normals, axis=1) 243 | residual_vectors: np.ndarray = (temp_normals.T * residuals).T 244 | return residual_vectors 245 | 246 | def _get_weights( 247 | self, 248 | fixed: np.ndarray, 249 | normals: np.ndarray, 250 | moving: np.ndarray, 251 | alpha: float, 252 | beta: float, 253 | ) -> diags: 254 | """ 255 | A dynamic weight function from an as yet unpublished manuscript. Details 256 | will be inserted once the manuscript is published. Traditional robust 257 | least squares weight functions rescale the errors on each iteration; 258 | this method essentially rescales the weight funtion on each iteration 259 | instead. It appears to work slightly better than traditional methods. 260 | 261 | Parameters 262 | ---------- 263 | fixed: np.array 264 | Array of fixed points ordered to correspond to the moving points 265 | normals: np.array 266 | Array of normal vectors corresponding to the fixed points 267 | moving: np.array 268 | Array of points to be transformed to the fixed point locations 269 | alpha: float 270 | Scalar value that controls how the weight function changes 271 | beta: float 272 | Scalar value that loosely represents the random noise in the data 273 | 274 | Returns 275 | ------- 276 | weights: diags 277 | Sparse matrix of weights 278 | """ 279 | r = np.sum((moving - fixed) * normals, axis=1) 280 | if alpha != 0: 281 | weights = (1 + (r / beta) ** 2) ** (alpha / 2 - 1) 282 | else: 283 | weights = beta**2 / (beta**2 + r**2) 284 | 285 | return diags(weights) 286 | 287 | def _apply_transform(self, points: np.ndarray, transform: np.ndarray) -> np.ndarray: 288 | """ 289 | Applies a 4x4 homogeneous transformation matrix to an array of 3D point 290 | coordinates. 291 | 292 | Parameters 293 | ---------- 294 | points: np.array 295 | Array of 3D points to be transformed 296 | transform: np.array 297 | 4x4 transformation matrix to apply to points 298 | 299 | Returns 300 | ------- 301 | transformed_points: np.array 302 | Array of transformed 3D points 303 | """ 304 | if transform.shape != (4, 4): 305 | raise ValueError( 306 | f"Transformation matrix is an invalid shape: {transform.shape}" 307 | ) 308 | points = np.hstack((points, np.ones((points.shape[0], 1)))) 309 | transformed_points: np.ndarray = np.transpose(transform @ points.T)[:, 0:3] 310 | return transformed_points 311 | 312 | def _scaled( 313 | self, fixed: np.ndarray, normals: np.ndarray, moving: np.ndarray, weights: diags 314 | ) -> Tuple[np.ndarray, float, float]: 315 | """ 316 | Solves a scaled rigid-body transformation (7-parameter) that minimizes 317 | the point to plane distances. 318 | 319 | Parameters 320 | ---------- 321 | fixed: np.array 322 | Array of fixed points ordered to correspond to the moving points 323 | normals: np.array 324 | Array of normal vectors corresponding to the fixed points 325 | moving: np.array 326 | Array of points to be transformed to the fixed point locations 327 | weights: scipy.sparse.diags 328 | Sparse matrix of weights for robustness against outliers 329 | 330 | Returns 331 | ------- 332 | transform: np.array 333 | 4x4 transformation matrix 334 | euler: float 335 | The rotation angle in terms of a single rotation axis 336 | distance: float 337 | The translation distance 338 | """ 339 | b = np.sum(fixed * normals, axis=1) 340 | A1 = np.cross(moving, normals) 341 | A2 = normals 342 | A3 = np.expand_dims(np.sum(moving * normals, axis=1), axis=1) 343 | A = np.hstack((A1, A2, A3)) 344 | 345 | if self.config["ICP_ROBUST"]: 346 | x = np.linalg.inv(A.T @ weights @ A) @ A.T @ weights @ b 347 | else: 348 | x = np.linalg.inv(A.T @ A) @ A.T @ b 349 | 350 | x[:3] /= x[6] 351 | 352 | R = np.eye(3) 353 | T = np.zeros(3) 354 | R[0, 0] = np.cos(x[2]) * np.cos(x[1]) 355 | R[0, 1] = -np.sin(x[2]) * np.cos(x[0]) + np.cos(x[2]) * np.sin(x[1]) * np.sin( 356 | x[0] 357 | ) 358 | R[0, 2] = np.sin(x[2]) * np.sin(x[0]) + np.cos(x[2]) * np.sin(x[1]) * np.cos( 359 | x[0] 360 | ) 361 | R[1, 0] = np.sin(x[2]) * np.cos(x[1]) 362 | R[1, 1] = np.cos(x[2]) * np.cos(x[0]) + np.sin(x[2]) * np.sin(x[1]) * np.sin( 363 | x[0] 364 | ) 365 | R[1, 2] = -np.cos(x[2]) * np.sin(x[0]) + np.sin(x[2]) * np.sin(x[1]) * np.cos( 366 | x[0] 367 | ) 368 | R[2, 0] = -np.sin(x[1]) 369 | R[2, 1] = np.cos(x[1]) * np.sin(x[0]) 370 | R[2, 2] = np.cos(x[1]) * np.cos(x[0]) 371 | T[0] = x[3] 372 | T[1] = x[4] 373 | T[2] = x[5] 374 | c = x[6] 375 | 376 | transform = np.eye(4) 377 | transform[:3, :3] = c * R 378 | transform[:3, 3] = T 379 | 380 | euler = np.rad2deg(np.arccos(np.clip((np.trace(R) - 1) / 2, -1, 1))) 381 | distance = np.sqrt(np.sum(T**2)) 382 | 383 | return transform, euler, distance 384 | 385 | def _unscaled( 386 | self, fixed: np.ndarray, normals: np.ndarray, moving: np.ndarray, weights: diags 387 | ) -> Tuple[np.ndarray, float, float]: 388 | """ 389 | Solves a rigid-body transformation (6-parameter) that minimizes 390 | the point to plane distances. 391 | 392 | Parameters 393 | ---------- 394 | fixed: np.array 395 | Array of fixed points ordered to correspond to the moving points 396 | normals: np.array 397 | Array of normal vectors corresponding to the fixed points 398 | moving: np.array 399 | Array of points to be transformed to the fixed point locations 400 | weights: scipy.sparse.diags 401 | Sparse matrix of weights for robustness against outliers 402 | 403 | Returns 404 | ------- 405 | transform: np.array 406 | 4x4 transformation matrix 407 | euler: float 408 | The rotation angle in terms of a single rotation axis 409 | distance: float 410 | The translation distance 411 | """ 412 | b1 = np.sum(fixed * normals, axis=1) 413 | b2 = np.sum(moving * normals, axis=1) 414 | b = np.expand_dims(b1 - b2, axis=1) 415 | A1 = np.cross(moving, normals) 416 | A2 = normals 417 | A = np.hstack((A1, A2)) 418 | 419 | if self.config["ICP_ROBUST"]: 420 | x = np.linalg.inv(A.T @ weights @ A) @ A.T @ weights @ b 421 | else: 422 | x = np.linalg.inv(A.T @ A) @ A.T @ b 423 | 424 | R = np.eye(3) 425 | T = np.zeros(3) 426 | R[0, 0] = np.cos(x[2]) * np.cos(x[1]) 427 | R[0, 1] = -np.sin(x[2]) * np.cos(x[0]) + np.cos(x[2]) * np.sin(x[1]) * np.sin( 428 | x[0] 429 | ) 430 | R[0, 2] = np.sin(x[2]) * np.sin(x[0]) + np.cos(x[2]) * np.sin(x[1]) * np.cos( 431 | x[0] 432 | ) 433 | R[1, 0] = np.sin(x[2]) * np.cos(x[1]) 434 | R[1, 1] = np.cos(x[2]) * np.cos(x[0]) + np.sin(x[2]) * np.sin(x[1]) * np.sin( 435 | x[0] 436 | ) 437 | R[1, 2] = -np.cos(x[2]) * np.sin(x[0]) + np.sin(x[2]) * np.sin(x[1]) * np.cos( 438 | x[0] 439 | ) 440 | R[2, 0] = -np.sin(x[1]) 441 | R[2, 1] = np.cos(x[1]) * np.sin(x[0]) 442 | R[2, 2] = np.cos(x[1]) * np.cos(x[0]) 443 | T[0] = x[3] 444 | T[1] = x[4] 445 | T[2] = x[5] 446 | 447 | transform = np.eye(4) 448 | transform[:3, :3] = R 449 | transform[:3, 3] = T 450 | 451 | euler = np.rad2deg(np.arccos(np.clip((np.trace(R) - 1) / 2, -1, 1))) 452 | distance = np.sqrt(np.sum(T**2)) 453 | 454 | return transform, euler, distance 455 | 456 | def _output(self) -> None: 457 | """ 458 | Stores registration results in a dictionary and writes them to a file 459 | """ 460 | X = self.transformation 461 | R = X[0:3, 0:3] 462 | c = np.sqrt(R[0, 0] ** 2 + R[1, 0] ** 2 + R[2, 0] ** 2) 463 | omega = np.rad2deg(np.arctan2(R[2, 1] / c, R[2, 2] / c)) 464 | phi = np.rad2deg(-np.arcsin(R[2, 0] / c)) 465 | kappa = np.rad2deg(np.arctan2(R[1, 0] / c, R[0, 0] / c)) 466 | tx = X[0, 3] 467 | ty = X[1, 3] 468 | tz = X[2, 3] 469 | 470 | self.registration_parameters: RegistrationParameters = { 471 | "matrix": X, 472 | "omega": omega, 473 | "phi": phi, 474 | "kappa": kappa, 475 | "trans_x": tx, 476 | "trans_y": ty, 477 | "trans_z": tz, 478 | "scale": c, 479 | "n_pairs": self.number_points, 480 | "rmse_x": self.rmse_xyz[0], 481 | "rmse_y": self.rmse_xyz[1], 482 | "rmse_z": self.rmse_xyz[2], 483 | "rmse_3d": self.rmse_3d, 484 | } 485 | output_file = os.path.join(self.config["OUTPUT_DIR"], "registration.txt") 486 | 487 | self.logger.info(f"Saving ICP registration parameters to: {output_file}") 488 | with open(output_file, "a", encoding="utf_8") as f: 489 | f.write("ICP REGISTRATION") 490 | f.write("\n----------------") 491 | f.write(f"\nTransformation matrix: \n {X}") 492 | f.write("\nTransformation Parameters:") 493 | f.write(f"\nOmega = {omega:.3f} degrees") 494 | f.write(f"\nPhi = {phi:.3f} degrees") 495 | f.write(f"\nKappa = {kappa:.3f} degrees") 496 | f.write(f"\nX Translation = {tx:.3f}") 497 | f.write(f"\nY Translation = {ty:.3f}") 498 | f.write(f"\nZ Translation = {tz:.3f}") 499 | f.write(f"\nScale = {c:.6f}") 500 | f.write(f"\nNumber of pairs = {self.number_points}") 501 | f.write("\nRMSEs:") 502 | f.write( 503 | f"\nX = +/-{self.rmse_xyz[0]:.3f}," 504 | f"\nY = +/-{self.rmse_xyz[1]:.3f}," 505 | f"\nZ = +/-{self.rmse_xyz[2]:.3f}," 506 | f"\n3D = +/-{self.rmse_3d:.3f}" 507 | ) 508 | f.write("\nPropagated Error:") 509 | f.write( 510 | "\nAssumed global 3D error = +/-3m" 511 | f"\n3D_RMSE = +/-{self.rmse_3d:.3f}" 512 | "\nTotal Error is computed as √(global_3d_error²+3D_RMSE²)" 513 | f"\nTotal Error = +/-{math.hypot(3, self.rmse_3d)}" 514 | ) 515 | f.write("\n\n") 516 | -------------------------------------------------------------------------------- /src/vcd/__init__.py: -------------------------------------------------------------------------------- 1 | from vcd.main import VcdRunConfig 2 | from vcd.meshing import Mesh 3 | from vcd.preprocessing import PointCloud 4 | from vcd.preprocessing import VCD 5 | from vcd.preprocessing import VCDParameters 6 | -------------------------------------------------------------------------------- /src/vcd/__main__.py: -------------------------------------------------------------------------------- 1 | from .main import main 2 | 3 | main() -------------------------------------------------------------------------------- /src/vcd/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | main.py 3 | """ 4 | import argparse 5 | import dataclasses 6 | import os 7 | import time 8 | from typing import Tuple 9 | 10 | import yaml 11 | from codem import __version__ 12 | from codem.lib.log import Log 13 | from distutils.util import strtobool 14 | from vcd.meshing.mesh import Mesh 15 | from vcd.preprocessing.preprocess import PointCloud 16 | from vcd.preprocessing.preprocess import VCD 17 | from vcd.preprocessing.preprocess import VCDParameters 18 | 19 | 20 | @dataclasses.dataclass 21 | class VcdRunConfig: 22 | BEFORE: str 23 | AFTER: str 24 | SPACING: float = 0.43 25 | GROUNDHEIGHT: float = 1.0 26 | RESOLUTION: float = 2.0 27 | VERBOSE: bool = False 28 | MIN_POINTS: int = 30 29 | CLUSTER_TOLERANCE: float = 2.0 30 | CULL_CLUSTER_IDS: Tuple[int, ...] = (-1, 0) 31 | CLASS_LABELS: Tuple[int, ...] = (2, 6) 32 | OUTPUT_DIR: str = "." 33 | COLORMAP: str = "RdBu" 34 | TRUST_LABELS: bool = False 35 | COMPUTE_HAG: bool = False 36 | LOG_TYPE: str = "rich" 37 | WEBSOCKET_URL: str = "127.0.0.1:8889" 38 | 39 | 40 | def __post_init__(self) -> None: 41 | # set output directory 42 | if self.OUTPUT_DIR is None: 43 | current_time = time.localtime(time.time()) 44 | timestamp = "%d-%02d-%02d_%02d-%02d-%02d" % ( 45 | current_time.tm_year, 46 | current_time.tm_mon, 47 | current_time.tm_mday, 48 | current_time.tm_hour, 49 | current_time.tm_min, 50 | current_time.tm_sec, 51 | ) 52 | 53 | output_dir = os.path.join(os.path.dirname(self.AFTER), f"vcd_{timestamp}") 54 | os.mkdir(output_dir) 55 | self.OUTPUT_DIR = os.path.abspath(output_dir) 56 | 57 | # validate attributes 58 | if not os.path.exists(self.BEFORE): 59 | raise FileNotFoundError(f"Before file {self.BEFORE} not found.") 60 | if not os.path.exists(self.AFTER): 61 | raise FileNotFoundError(f"After file {self.AFTER} not found.") 62 | 63 | # dump config 64 | config_path = os.path.join(self.OUTPUT_DIR, "config.yml") 65 | with open(config_path, "w") as f: 66 | yaml.safe_dump( 67 | dataclasses.asdict(self), 68 | f, 69 | default_flow_style=False, 70 | sort_keys=False, 71 | explicit_start=True, 72 | ) 73 | return None 74 | 75 | 76 | def str2bool(v: str) -> bool: 77 | return bool(strtobool(v)) 78 | 79 | 80 | def get_args() -> argparse.Namespace: 81 | ap = argparse.ArgumentParser( 82 | description="CODEM-VCD: LiDAR Vertical Change Detection" 83 | ) 84 | ap.add_argument( 85 | "before", 86 | type=str, 87 | help="Before LiDAR scan", 88 | ) 89 | ap.add_argument( 90 | "after", 91 | type=str, 92 | help="After LiDAR scan", 93 | ) 94 | ap.add_argument( 95 | "--spacing-override", 96 | type=float, 97 | default=VcdRunConfig.SPACING, 98 | help="Use specified spacing instead of computing from data", 99 | ) 100 | ap.add_argument( 101 | "--ground-height", 102 | type=float, 103 | default=VcdRunConfig.GROUNDHEIGHT, 104 | help="Ground filtering height", 105 | ) 106 | ap.add_argument( 107 | "--resolution", 108 | type=float, 109 | default=VcdRunConfig.RESOLUTION, 110 | help="Raster output resolution", 111 | ) 112 | ap.add_argument( 113 | "--min-points", 114 | type=int, 115 | default=VcdRunConfig.MIN_POINTS, 116 | help="Minimum points to cluster around", 117 | ) 118 | ap.add_argument( 119 | "--cluster-tolerance", 120 | type=float, 121 | default=VcdRunConfig.CLUSTER_TOLERANCE, 122 | help="Cluster tolerance used by pdal.Filter.cluster", 123 | ) 124 | ap.add_argument( 125 | "--cull-cluster-ids", 126 | type=str, 127 | default=",".join(map(str, VcdRunConfig.CULL_CLUSTER_IDS)), 128 | help="Comma separated list of cluster IDs to cull when producing the meshes", 129 | ) 130 | ap.add_argument( 131 | "--class-labels", 132 | type=str, 133 | default=",".join(map(str, VcdRunConfig.CLASS_LABELS)), 134 | help="Comma separated list of classification labels to use when producing the meshes", 135 | ) 136 | ap.add_argument( 137 | "-v", "--verbose", action="count", default=0, help="turn on verbose logging" 138 | ) 139 | ap.add_argument( 140 | "--colormap", 141 | type=str, 142 | default=VcdRunConfig.COLORMAP, 143 | help=( 144 | "Colormap to apply to generated output files where supported. Name has " 145 | "to align with a matplotlib named colormap. See " 146 | "https://matplotlib.org/stable/tutorials/colors/colormaps.html#diverging " 147 | "for list of options." 148 | ), 149 | ) 150 | ap.add_argument( 151 | "--trust-labels", 152 | action="store_true", 153 | help=( 154 | "Trusts existing classification labels in the removal of vegetation/noise, " 155 | "otherwise return information is used to approximate vegetation/noise " 156 | "detection." 157 | ), 158 | ) 159 | ap.add_argument( 160 | "--compute-hag", 161 | action="store_true", 162 | help=( 163 | "Compute height above ground between after scan (non-ground) and before " 164 | "scan (ground), otherwise compute to nearest neighbor from after to before." 165 | ), 166 | ) 167 | ap.add_argument( 168 | "--output-dir", "-o", type=str, help="Directory to place VCD output" 169 | ) 170 | ap.add_argument( 171 | "--version", 172 | action="version", 173 | version=f"{__version__}", 174 | help="Display codem version information", 175 | ) 176 | ap.add_argument( 177 | "--log-type", 178 | "-l", 179 | type=str, 180 | default=VcdRunConfig.LOG_TYPE, 181 | help="Specify how to log codem output, options include websocket, rich or console", 182 | ) 183 | ap.add_argument( 184 | "--websocket-url", 185 | type=str, 186 | default=VcdRunConfig.WEBSOCKET_URL, 187 | help="Url to websocket receiver to connect to" 188 | ) 189 | return ap.parse_args() 190 | 191 | 192 | def create_config(args: argparse.Namespace) -> VCDParameters: 193 | config = VcdRunConfig( 194 | os.fsdecode(os.path.abspath(args.before)), 195 | os.fsdecode(os.path.abspath(args.after)), 196 | SPACING=float(args.spacing_override), 197 | VERBOSE=args.verbose, 198 | GROUNDHEIGHT=float(args.ground_height), 199 | RESOLUTION=float(args.resolution), 200 | MIN_POINTS=int(args.min_points), 201 | CLUSTER_TOLERANCE=float(args.cluster_tolerance), 202 | CULL_CLUSTER_IDS=tuple(map(int, args.cull_cluster_ids.split(","))), 203 | CLASS_LABELS=tuple(map(int, args.class_labels.split(","))), 204 | TRUST_LABELS=args.trust_labels, 205 | COMPUTE_HAG=args.compute_hag, 206 | OUTPUT_DIR=args.output_dir, 207 | LOG_TYPE=args.log_type, 208 | WEBSOCKET_URL=args.websocket_url 209 | ) 210 | config_dict = dataclasses.asdict(config) 211 | log = Log(config_dict) 212 | config_dict["log"] = log 213 | return config_dict # type: ignore 214 | 215 | 216 | def run_stdout_console(config: VCDParameters) -> None: 217 | print("/************************************\\") 218 | print("* VCD *") 219 | print("**************************************") 220 | print("* AUTHORS: Brad Chambers & *") 221 | print("* Howard Butler *") 222 | print("* DEVELOPED FOR: CRREL/NEGGS *") 223 | print("\\************************************/") 224 | print() 225 | print("==============PARAMETERS==============") 226 | 227 | logger = config["log"].logger 228 | for key, value in config.items(): 229 | logger.info(f"{key} = {value}") 230 | before = PointCloud(config, "BEFORE") 231 | after = PointCloud(config, "AFTER") 232 | v = VCD(before, after) 233 | v.compute_indexes() 234 | v.make_products() 235 | v.cluster() 236 | v.rasterize() 237 | m = Mesh(v) 238 | m.write("cluster", m.cluster(v.clusters)) 239 | v.save() 240 | 241 | 242 | def run_no_console(config: VCDParameters) -> None: 243 | from codem.lib.progress import WebSocketProgress 244 | 245 | logger = config["log"].logger 246 | 247 | 248 | with WebSocketProgress(config["WEBSOCKET_URL"]) as progress: 249 | change_detection = progress.add_task("Vertical Change Detection...", total=100) 250 | 251 | for key, value in config.items(): 252 | logger.info(f"{key} = {value}") 253 | 254 | before = PointCloud(config, "BEFORE") 255 | progress.advance(change_detection, 14) 256 | 257 | after = PointCloud(config, "AFTER") 258 | progress.advance(change_detection, 15) 259 | 260 | v = VCD(before, after) 261 | progress.advance(change_detection, 15) 262 | 263 | v.compute_indexes() 264 | progress.advance(change_detection, 15) 265 | 266 | v.make_products() 267 | progress.advance(change_detection, 15) 268 | 269 | v.cluster() 270 | progress.advance(change_detection, 15) 271 | 272 | v.rasterize() 273 | progress.advance(change_detection, 15) 274 | 275 | m = Mesh(v) 276 | m.write("cluster", m.cluster(v.clusters)) 277 | v.save() 278 | 279 | progress.advance(change_detection, 10) 280 | 281 | 282 | def run_rich_console(config: VCDParameters) -> None: 283 | """ 284 | Preprocess and register the provided data 285 | 286 | Parameters 287 | ---------- 288 | config: dict 289 | Dictionary of configuration parameters 290 | """ 291 | from rich.console import Console # type: ignore 292 | from rich.progress import Progress # type: ignore 293 | from rich.progress import SpinnerColumn # type: ignore 294 | from rich.progress import TimeElapsedColumn # type: ignore 295 | 296 | console = Console() 297 | logger = config["log"].logger 298 | with Progress( 299 | SpinnerColumn(), 300 | *Progress.get_default_columns(), 301 | TimeElapsedColumn(), 302 | console=console, 303 | ) as progress: 304 | change_detection = progress.add_task("Vertical Change Detection...", total=100) 305 | 306 | # characters are problematic on a windows console 307 | console.print("/************************************\\", justify="center") 308 | console.print("* VCD *", justify="center") 309 | console.print("**************************************", justify="center") 310 | console.print("* AUTHORS: Brad Chambers & *", justify="center") 311 | console.print("* Howard Butler *", justify="center") 312 | console.print("* DEVELOPED FOR: CRREL/NEGGS *", justify="center") 313 | console.print("\\************************************/", justify="center") 314 | console.print() 315 | console.print("==============PARAMETERS==============", justify="center") 316 | for key, value in config.items(): 317 | logger.info(f"{key} = {value}") 318 | progress.advance(change_detection, 1) 319 | 320 | console.print("==========PREPROCESSING DATA==========", justify="center") 321 | console.print("==========Filtering 'before' data ====", justify="center") 322 | before = PointCloud(config, "BEFORE") 323 | progress.advance(change_detection, 14) 324 | console.print("==========Filtering 'after' data =====", justify="center") 325 | after = PointCloud(config, "AFTER") 326 | progress.advance(change_detection, 15) 327 | console.print( 328 | "==========Computing indexes for comparison =====", justify="center" 329 | ) 330 | v = VCD(before, after) 331 | v.compute_indexes() 332 | progress.advance(change_detection, 15) 333 | console.print("========== Extracting differences ", justify="center") 334 | v.make_products() 335 | progress.advance(change_detection, 15) 336 | console.print("========== Clustering ", justify="center") 337 | v.cluster() 338 | progress.advance(change_detection, 15) 339 | console.print("========== Rasterizing products ", justify="center") 340 | v.rasterize() 341 | progress.advance(change_detection, 15) 342 | console.print("========== Meshing products ", justify="center") 343 | 344 | m = Mesh(v) 345 | m.write("cluster", m.cluster(v.clusters)) 346 | 347 | v.save() 348 | progress.advance(change_detection, 10) 349 | 350 | 351 | def main() -> None: 352 | args = get_args() 353 | config = create_config(args) 354 | if config["LOG_TYPE"] == "rich": 355 | run_rich_console(config) 356 | elif config["LOG_TYPE"] == "websocket": 357 | run_no_console(config) 358 | else: 359 | run_stdout_console(config) # type: ignore 360 | return None 361 | 362 | 363 | if __name__ == "__main__": 364 | main() 365 | -------------------------------------------------------------------------------- /src/vcd/meshing/__init__.py: -------------------------------------------------------------------------------- 1 | from .mesh import Mesh 2 | -------------------------------------------------------------------------------- /src/vcd/meshing/mesh.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os 3 | from typing import List 4 | 5 | import numpy as np 6 | import pdal 7 | import shapefile 8 | import trimesh 9 | from pyproj.enums import WktVersion 10 | from shapefile import TRIANGLE_STRIP 11 | from vcd.preprocessing.preprocess import VCD 12 | 13 | 14 | class Mesh: 15 | def __init__(self, vcd: VCD) -> None: 16 | self.vcd = vcd 17 | 18 | def cluster(self, dataset: pdal.Filter.cluster) -> List[trimesh.Trimesh]: 19 | 20 | clusters = [] 21 | dimension = "ClusterID" 22 | pipeline = """ 23 | { 24 | "pipeline": [ 25 | { 26 | "type":"filters.groupby", 27 | "dimension":"ClusterID" 28 | } 29 | ] 30 | } """ 31 | reader = pdal.Pipeline(pipeline, arrays=dataset.arrays) 32 | reader.execute() 33 | 34 | for arr in reader.arrays: 35 | 36 | if len(arr) < 5: 37 | if len(arr): 38 | cluster_id = arr[0][dimension] 39 | print( 40 | f"Not enough points to cluster {cluster_id}. We have {len(arr)} and need 5" 41 | ) 42 | else: 43 | print("Cluster has no points!") 44 | continue 45 | 46 | x = arr["X"] 47 | y = arr["Y"] 48 | z = arr["Z"] 49 | cluster_id = arr[0][dimension] 50 | classification = arr[0]["Classification"] 51 | status = np.average(arr["dZ3d"]) 52 | 53 | points = np.vstack((x, y, z)).T 54 | 55 | self.vcd.before.logger.logger.info( 56 | f"computing Delaunay of {len(points)} points" 57 | ) 58 | 59 | pc = trimesh.points.PointCloud(points) 60 | 61 | hull = pc.convex_hull 62 | hull.cluster_id = cluster_id 63 | hull.classification = classification 64 | hull.status = status 65 | 66 | # cull out some specific cluster IDs 67 | culls = self.vcd.before.config["CULL_CLUSTER_IDS"] 68 | 69 | if cluster_id not in culls: 70 | clusters.append(hull) 71 | 72 | return clusters 73 | 74 | def write(self, filename: str, clusters: List[trimesh.Trimesh]) -> None: 75 | with contextlib.suppress(FileExistsError): 76 | os.mkdir(os.path.join(self.vcd.before.config["OUTPUT_DIR"], "meshes")) 77 | outfile = os.path.join(self.vcd.before.config["OUTPUT_DIR"], "meshes", filename) 78 | if self.vcd.before.crs is None: 79 | # mypy must be satisfied with its optional! 80 | raise RuntimeError 81 | wkt = self.vcd.before.crs.to_wkt(WktVersion.WKT1_ESRI) 82 | self.vcd.before.logger.logger.info(f"Saving mesh data to {filename}") 83 | 84 | with shapefile.Writer(outfile) as w: 85 | w.field("volume", "N", decimal=2) 86 | w.field("area", "N", decimal=2) 87 | w.field("clusterid", "N") 88 | w.field("ground", "L") 89 | w.field("status", "C") 90 | 91 | # Save CRS WKT 92 | with open(f"{outfile}.prj", "w") as f: 93 | f.write(wkt) 94 | 95 | for cluster in clusters: 96 | w.multipatch( 97 | cluster.triangles, 98 | partTypes=[TRIANGLE_STRIP] * len(cluster.triangles), 99 | ) # one type for each part 100 | is_ground = cluster.classification == 2 101 | if cluster.status <= 0: 102 | status = "Fled" 103 | else: 104 | status = "New" 105 | w.record(cluster.volume, cluster.area, cluster.cluster_id, is_ground, status) 106 | -------------------------------------------------------------------------------- /src/vcd/preprocessing/__init__.py: -------------------------------------------------------------------------------- 1 | from .preprocess import PointCloud 2 | from .preprocess import VCD 3 | from .preprocess import VCDParameters 4 | -------------------------------------------------------------------------------- /src/vcd/preprocessing/preprocess.py: -------------------------------------------------------------------------------- 1 | """ 2 | preprocess.py 3 | Project: CRREL-NEGGS University of Houston Collaboration 4 | Date: February 2021 5 | 6 | """ 7 | import contextlib 8 | import os 9 | import re 10 | from typing import List 11 | from typing import NamedTuple 12 | from typing import Optional 13 | from typing import Tuple 14 | 15 | import matplotlib.colors as colors 16 | import matplotlib.pyplot as plt 17 | import numpy as np 18 | import numpy.lib.recfunctions as rfn 19 | import pandas as pd 20 | import pdal 21 | from codem import __version__ 22 | from codem.lib.log import Log 23 | from pyproj import CRS 24 | from pyproj.aoi import AreaOfInterest 25 | from pyproj.database import query_utm_crs_info # type: ignore 26 | from pyproj.transformer import TransformerGroup 27 | from scipy.spatial import cKDTree 28 | from typing_extensions import TypedDict 29 | 30 | 31 | class VCDParameters(TypedDict): 32 | GROUNDHEIGHT: np.ndarray 33 | RESOLUTION: np.float64 34 | OUTPUT_DIR: str 35 | BEFORE: str 36 | AFTER: str 37 | MIN_POINTS: int 38 | CLUSTER_TOLERANCE: float 39 | CULL_CLUSTER_IDS: Tuple[int, ...] 40 | CLASS_LABELS: Tuple[int, ...] 41 | COLORMAP: str 42 | TRUST_LABELS: bool 43 | COMPUTE_HAG: bool 44 | LOG_TYPE: str 45 | WEBSOCKET_URL: str 46 | log: Log 47 | 48 | 49 | class Product(NamedTuple): 50 | df: pd.DataFrame 51 | z_name: str 52 | description: str = "" 53 | 54 | @property 55 | def slug(self) -> str: 56 | """Adapted from https://stackoverflow.com/a/8366771/498396""" 57 | return re.sub(r"[^0-9A-Za-z.]", "-", self.description.lower()) 58 | 59 | 60 | def get_json(filename: str) -> str: 61 | with contextlib.suppress(IOError): 62 | with open(filename, "r") as f: 63 | content = f.read() 64 | return content 65 | 66 | 67 | class PointCloud: 68 | """ 69 | A class for storing and preparing geospatial data 70 | 71 | Parameters 72 | ---------- 73 | config: VCDParameters 74 | Dictionary of configuration options 75 | fnd: bool 76 | Whether the file is foundation data 77 | 78 | """ 79 | 80 | def __init__(self, config: VCDParameters, key: str) -> None: 81 | self.logger = config["log"] 82 | if key in {"BEFORE", "AFTER"}: 83 | # see https://github.com/python/mypy/issues/7178 84 | self.filename = config[key] # type: ignore 85 | else: 86 | raise ValueError 87 | self.config = config 88 | self.crs: Optional[CRS] = None 89 | self.utm = "" 90 | self.pipeline = self.open() 91 | 92 | if len(self.pipeline.arrays) > 1: 93 | raise NotImplementedError("VCD between multiple views is not supported") 94 | self.df = pd.DataFrame(self.pipeline.arrays[0]) 95 | 96 | # drop the color information if it is present 97 | self.df = self.df.drop(columns=["Red", "Green", "Blue"], errors="ignore") 98 | 99 | def open(self) -> pdal.Pipeline: 100 | def _get_utm(pipeline: pdal.Pipeline) -> pdal.Pipeline: 101 | data = pipeline.quickinfo 102 | is_reader = [[k.split(".")[0] == "readers", k] for k in data.keys()] 103 | for k in is_reader: 104 | if k[0]: # we are a reader 105 | reader_info = data[k[1]] 106 | bounds = reader_info["bounds"] 107 | srs = CRS.from_user_input(reader_info["srs"]["compoundwkt"]) 108 | 109 | # we just take the first one. If there's more we are screwed 110 | break 111 | 112 | tg = TransformerGroup(srs, 4326) 113 | for transformer in tg.transformers: 114 | dd = transformer.transform( 115 | (bounds["minx"], bounds["maxx"]), (bounds["miny"], bounds["maxy"]) 116 | ) 117 | 118 | # stolen from Alan https://gis.stackexchange.com/a/423614/350 119 | # dd now in the form ((41.469221251843926, 41.47258675464548), (-93.68979255724548, -93.68530098082489)) 120 | aoi = AreaOfInterest( 121 | west_lon_degree=dd[1][0], 122 | south_lat_degree=dd[0][0], 123 | east_lon_degree=dd[1][1], 124 | north_lat_degree=dd[0][1], 125 | ) 126 | 127 | utm_crs_list = query_utm_crs_info( 128 | area_of_interest=aoi, datum_name="WGS 84" 129 | ) 130 | # when we get a list that has at least one element, we can break 131 | if utm_crs_list: 132 | break 133 | else: 134 | raise ValueError( 135 | "Unable to find transform not resulting in all finite values" 136 | ) 137 | 138 | crs = CRS.from_epsg(utm_crs_list[0].code) 139 | 140 | utm = f"EPSG:{crs.to_epsg()}" 141 | pipeline |= pdal.Filter.reprojection(out_srs=utm) 142 | 143 | pipeline.crs = crs 144 | pipeline.utm = utm 145 | return pipeline 146 | 147 | filters: pdal.Pipeline 148 | if os.path.splitext(self.filename)[-1] == ".json": 149 | pipeline = get_json(self.filename) 150 | filters = pdal.Pipeline(pipeline) 151 | self.logger.logger.info("Loaded JSON pipeline ") 152 | else: 153 | filters = pdal.Reader(self.filename).pipeline() 154 | self.logger.logger.info(f"Loaded {self.filename}") 155 | 156 | filters = _get_utm(filters) 157 | 158 | self.crs = filters.crs 159 | self.utm = filters.utm 160 | 161 | # Do not (fully) trust original classifications -- original workflow. 162 | if not self.config["TRUST_LABELS"]: 163 | filters |= pdal.Filter.range(limits="Classification![7:7]") 164 | filters |= pdal.Filter.range(limits="Classification![18:)") 165 | filters |= pdal.Filter.range(limits="Classification![9:9]") 166 | filters |= pdal.Filter.returns(groups="only") 167 | filters |= pdal.Filter.elm(cell=20.0) 168 | filters |= pdal.Filter.outlier(where="Classification!=7") 169 | filters |= pdal.Filter.range(limits="Classification![7:7]") 170 | filters |= pdal.Filter.assign(assignment="Classification[:]=1") 171 | filters |= pdal.Filter.smrf() 172 | 173 | else: 174 | filters |= pdal.Filter.returns(groups="only") 175 | 176 | filters.execute() 177 | self.pipeline = filters 178 | return filters 179 | 180 | 181 | class VCD: 182 | def __init__(self, before: PointCloud, after: PointCloud) -> None: 183 | self.before = before 184 | self.after = after 185 | self.products: List[Product] = [] 186 | self.gh = before.config["GROUNDHEIGHT"] 187 | self.resolution = before.config["RESOLUTION"] 188 | self.trust_labels = before.config["TRUST_LABELS"] 189 | self.compute_hag = before.config["COMPUTE_HAG"] 190 | 191 | def compute_indexes(self) -> None: 192 | after = self.after.df 193 | before = self.before.df 194 | 195 | # Compute height as delta Z between nearest point in before cloud from the after cloud -- original workflow. 196 | if not self.before.config["COMPUTE_HAG"]: 197 | tree3d = cKDTree(before[["X", "Y", "Z"]].to_numpy()) 198 | _, i3d = tree3d.query(after[["X", "Y", "Z"]].to_numpy(), k=1) 199 | after["dZ3d"] = after.Z - before.iloc[i3d].Z.values 200 | 201 | # Compute height as HAG, treating after as non-ground and before as ground -- new workflow. 202 | else: 203 | # Assing after non-ground, before ground. 204 | after["TempClassification"] = 1 205 | before["TempClassification"] = 2 206 | 207 | # Merge clouds. 208 | allpoints = pd.concat([after, before]) 209 | 210 | # Stash original classifications, then compute HAG using TempClassification. Pop the original classifications. 211 | pipeline = pdal.Pipeline(dataframes=[allpoints]) 212 | pipeline |= pdal.Filter.ferry( 213 | dimensions="TempClassification=>Classification" 214 | ) 215 | pipeline |= pdal.Filter.hag_delaunay() 216 | pipeline.execute() 217 | 218 | # Assign HAG as dZ3d and d3 in keeping with the original approach. 219 | result = pipeline.get_dataframe(0) 220 | after["dZ3d"] = result["HeightAboveGround"] 221 | 222 | def cluster(self) -> None: 223 | after = self.after.df 224 | gh = self.gh 225 | 226 | thresholdFilter = pdal.Filter.range(limits="dZ3d![-{gh}:{gh}]".format(gh=gh)) 227 | 228 | conditions = [ 229 | f"Classification=={id}" for id in self.after.config["CLASS_LABELS"] 230 | ] 231 | expression = " || ".join(conditions) 232 | rangeFilter = pdal.Filter.expression(expression=expression) 233 | 234 | clusterFilter = pdal.Filter.cluster( 235 | min_points=self.after.config["MIN_POINTS"], 236 | tolerance=self.after.config["CLUSTER_TOLERANCE"], 237 | ) 238 | 239 | conditions = [ 240 | f"ClusterID!={id}" for id in self.after.config["CULL_CLUSTER_IDS"] 241 | ] 242 | expression = " && ".join(conditions) 243 | clusterIdFilter = pdal.Filter.expression(expression=expression) 244 | 245 | array = after.to_records() 246 | self.clusters = pdal.Pipeline( 247 | [thresholdFilter, rangeFilter, clusterFilter, clusterIdFilter], [array] 248 | ) 249 | self.clusters.execute() 250 | cluster_df = pd.DataFrame(self.clusters.arrays[0]) 251 | 252 | # Encode the size of each cluster as a new dimension for analysis. 253 | cluster_df["ClusterSize"] = cluster_df.groupby(["ClusterID"])[ 254 | "ClusterID" 255 | ].transform("count") 256 | self.cluster_sizes = cluster_df["ClusterSize"].to_numpy() 257 | 258 | p = self.make_product( 259 | cluster_df.X, 260 | cluster_df.Y, 261 | cluster_df.ClusterID, 262 | description=f"Clusters greater than +/-{gh:.2f} height", 263 | ) 264 | self.products.append(p) 265 | 266 | def make_products(self) -> None: 267 | after = self.after.df 268 | p = self.make_product( 269 | after.X, after.Y, after.dZ3d, description="Before minus after" 270 | ) 271 | self.products.append(p) 272 | 273 | def make_product( 274 | self, 275 | x: pd.Series, 276 | y: pd.Series, 277 | z: pd.Series, 278 | description: str = "", 279 | ) -> pd.DataFrame: 280 | df = x.to_frame().join(y.to_frame()).join(z.to_frame()) 281 | return Product(df=df, z_name=z.name, description=description) 282 | 283 | def rasterize(self) -> None: 284 | resolution = self.before.config["RESOLUTION"] 285 | rasters_dir = os.path.join(self.before.config["OUTPUT_DIR"], "rasters") 286 | summary_dir = os.path.join( 287 | self.before.config["OUTPUT_DIR"], "rasters", "summary" 288 | ) 289 | products_dir = os.path.join( 290 | self.before.config["OUTPUT_DIR"], "rasters", "products" 291 | ) 292 | 293 | os.makedirs(rasters_dir, exist_ok=True) 294 | os.makedirs(summary_dir, exist_ok=True) 295 | os.makedirs(products_dir, exist_ok=True) 296 | 297 | def _rasterize(product: Product, utm: str) -> str: 298 | array = product.df.to_records() 299 | array = rfn.rename_fields(array, {product.z_name: "Z"}) 300 | 301 | outfile = os.path.join(products_dir, product.slug) + ".tif" 302 | 303 | metadata = ( 304 | f"TIFFTAG_XRESOLUTION={resolution}," 305 | f"TIFFTAG_YRESOLUTION={resolution}," 306 | f"TIFFTAG_IMAGEDESCRIPTION={product.description}," 307 | f"CODEM_VERSION={__version__}" 308 | ) 309 | gdalopts = ( 310 | "COMPRESS=LZW," "PREDICTOR=2," "OVERVIEW_COMPRESS=LZW," "BIGTIFF=YES" 311 | ) 312 | 313 | pipeline = pdal.Writer.gdal( 314 | filename=outfile, 315 | metadata=metadata, 316 | gdalopts=gdalopts, 317 | override_srs=utm, 318 | resolution=resolution, 319 | output_type="idw", 320 | ).pipeline(array) 321 | pipeline.execute() 322 | return outfile 323 | 324 | _ = [_rasterize(p, self.before.utm) for p in self.products] 325 | return None 326 | 327 | def save(self, format: str = ".las") -> None: 328 | with contextlib.suppress(FileExistsError): 329 | os.mkdir(os.path.join(self.before.config["OUTPUT_DIR"], "points")) 330 | 331 | # Determine Colormap 332 | flex_max = self.clusters.arrays[0]["dZ3d"].min() 333 | 334 | new_max = self.clusters.arrays[0]["dZ3d"].max() 335 | 336 | divnorm = colors.TwoSlopeNorm(vmin=flex_max, vcenter=0, vmax=new_max) 337 | # we are only writing the first point-clouds 338 | colormap = plt.colormaps[self.before.config["COLORMAP"]] 339 | 340 | # write point cloud output 341 | path = "clusters" 342 | array = self.clusters.arrays[0] 343 | sizes = self.cluster_sizes 344 | filename = os.path.join( 345 | self.before.config["OUTPUT_DIR"], "points", f"{path}{format}" 346 | ) 347 | 348 | # convert colors from [0. 1] floats to [0, 65535] per LAS spec 349 | rgb = np.array( 350 | [ 351 | colors.to_rgba_array(colormap(divnorm(array["dZ3d"]))) 352 | * np.iinfo(np.uint16).max 353 | ], 354 | dtype=np.uint16, 355 | )[0, :, :-1] 356 | 357 | array = rfn.append_fields( 358 | array, 359 | ["Red", "Green", "Blue", "ClusterSize"], 360 | [rgb[:, 0], rgb[:, 1], rgb[:, 2], sizes], 361 | ) 362 | 363 | crs = self.after.crs 364 | pipeline = pdal.Writer.las( 365 | filename=filename, 366 | extra_dims="all", 367 | a_srs=crs.to_string() if crs is not None else crs, 368 | ).pipeline(array) 369 | pipeline.execute() 370 | -------------------------------------------------------------------------------- /tests/data/aoi_shapefile/aoi.cpg: -------------------------------------------------------------------------------- 1 | UTF-8 2 | -------------------------------------------------------------------------------- /tests/data/aoi_shapefile/aoi.dbf: -------------------------------------------------------------------------------- 1 | z AIdN 0 -------------------------------------------------------------------------------- /tests/data/aoi_shapefile/aoi.prj: -------------------------------------------------------------------------------- 1 | PROJCS["WGS_1984_UTM_Zone_16N",GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",-87.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]],VERTCS["unknown",VDATUM["unknown"],PARAMETER["Vertical_Shift",0.0],PARAMETER["Direction",1.0],UNIT["Meter",1.0]] 2 | -------------------------------------------------------------------------------- /tests/data/aoi_shapefile/aoi.sbn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NCALM-UH/CODEM/2264bc062ad827ca0f4558b3d0fb5ad40a90dcb3/tests/data/aoi_shapefile/aoi.sbn -------------------------------------------------------------------------------- /tests/data/aoi_shapefile/aoi.sbx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NCALM-UH/CODEM/2264bc062ad827ca0f4558b3d0fb5ad40a90dcb3/tests/data/aoi_shapefile/aoi.sbx -------------------------------------------------------------------------------- /tests/data/aoi_shapefile/aoi.shp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NCALM-UH/CODEM/2264bc062ad827ca0f4558b3d0fb5ad40a90dcb3/tests/data/aoi_shapefile/aoi.shp -------------------------------------------------------------------------------- /tests/data/aoi_shapefile/aoi.shx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NCALM-UH/CODEM/2264bc062ad827ca0f4558b3d0fb5ad40a90dcb3/tests/data/aoi_shapefile/aoi.shx -------------------------------------------------------------------------------- /tests/data/dem.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NCALM-UH/CODEM/2264bc062ad827ca0f4558b3d0fb5ad40a90dcb3/tests/data/dem.tif -------------------------------------------------------------------------------- /tests/data/mesh.ply: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NCALM-UH/CODEM/2264bc062ad827ca0f4558b3d0fb5ad40a90dcb3/tests/data/mesh.ply -------------------------------------------------------------------------------- /tests/data/pc.laz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NCALM-UH/CODEM/2264bc062ad827ca0f4558b3d0fb5ad40a90dcb3/tests/data/pc.laz -------------------------------------------------------------------------------- /tests/data/pipeline.json: -------------------------------------------------------------------------------- 1 | { 2 | "pipeline": 3 | [ 4 | { 5 | "filename": "./tests/data/pc.laz", 6 | "type": "readers.las" 7 | }, 8 | { 9 | "type": "filters.expression", 10 | "expression": "Intensity < 250" 11 | }, 12 | { 13 | "type": "writers.gdal", 14 | "resolution": 1, 15 | "filename":"output.tif" 16 | } 17 | 18 | ] 19 | } 20 | 21 | -------------------------------------------------------------------------------- /tests/point_cloud.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | import warnings 5 | from math import cos 6 | from math import isclose 7 | from math import sin 8 | from typing import Dict 9 | from typing import List 10 | from typing import NamedTuple 11 | from typing import Optional 12 | from typing import Union 13 | 14 | import pdal 15 | 16 | 17 | class Translation(NamedTuple): 18 | x: float = 0.0 19 | y: float = 0.0 20 | z: float = 0.0 21 | 22 | 23 | class Rotation(NamedTuple): 24 | x: float = 0.0 25 | y: float = 0.0 26 | z: float = 0.0 27 | 28 | 29 | def manipulate_pc( 30 | pc_aoi: str, 31 | *, 32 | rotation: Optional[Rotation] = None, 33 | translation: Optional[Translation] = None, 34 | ) -> str: 35 | 36 | if rotation is None and translation is None: 37 | warnings.warn( 38 | UserWarning( 39 | "manipulate_pc was called but with no rotation or " 40 | "translation option specified." 41 | ) 42 | ) 43 | 44 | return pc_aoi 45 | 46 | transforms = [] 47 | if translation is not None: 48 | matrix = ( 49 | f"1 0 0 {translation.x} 0 1 0 {translation.y} 0 0 1 {translation.z} 0 0 0 1" 50 | ) 51 | transforms.append(matrix) 52 | if rotation is not None: 53 | if not isclose(rotation.x, 0): 54 | sinx = sin(rotation.x) 55 | cosx = cos(rotation.x) 56 | matrix = f"1 0 0 0 0 {cosx} {-sinx} 0 0 {sinx} {cosx} 0 0 0 0 1" 57 | transforms.append(matrix) 58 | if not isclose(rotation.y, 0): 59 | siny = sin(rotation.y) 60 | cosy = cos(rotation.y) 61 | matrix = f"{cosy} 0 {siny} 0 0 1 0 0 {-siny} 0 {cosy} 0 0 0 0 1" 62 | transforms.append(matrix) 63 | if not isclose(rotation.z, 0): 64 | sinz = sin(rotation.z) 65 | cosz = cos(rotation.z) 66 | matrix = f"{cosz} {-sinz} 0 0 {sinz} {cosz} 0 0 0 0 1 0 0 0 0 1" 67 | transforms.append(matrix) 68 | 69 | pipeline: List[Union[str, Dict[str, str]]] = [ 70 | {"type": "filters.transformation", "matrix": matrix} for matrix in transforms 71 | ] 72 | pipeline.insert(0, pc_aoi) 73 | 74 | temp_location = os.path.dirname(pc_aoi) 75 | output_file = tempfile.NamedTemporaryFile( 76 | dir=temp_location, suffix=".copc.laz", delete=False 77 | ).name 78 | pipeline.append({"type": "writers.copc", "filename": output_file, "forward": "all"}) 79 | p = pdal.Pipeline(json.dumps(pipeline)) 80 | p.execute() 81 | return output_file 82 | 83 | 84 | 85 | def pc_pipeline(tmp_location: str, pc_foundation: str, aoi_shapefile: str) -> str: 86 | os.makedirs(tmp_location, exist_ok=True) 87 | output_file = tempfile.NamedTemporaryFile( 88 | dir=tmp_location, suffix=".copc.laz", delete=False 89 | ).name 90 | from codem.preprocessing.preprocess import PipelineReader 91 | 92 | pipeline = PipelineReader(pc_foundation).get() 93 | 94 | pipeline |= pdal.Filter.ferry(dimensions="=>AOIDimension") 95 | pipeline |= pdal.Filter.assign(assignment="AOIDimension[:]=1") 96 | pipeline |= pdal.Filter.overlay( 97 | dimension="AOIDimension", datasource=aoi_shapefile, where="AOIDimension == 1" 98 | ) 99 | pipeline |= pdal.Filter.range(limits="AOIDimension[0:0]") 100 | 101 | pipeline |= pdal.Writer.copc(filename=output_file, forward="all") 102 | pipeline.execute() 103 | return output_file 104 | 105 | -------------------------------------------------------------------------------- /tests/raster.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | import warnings 4 | from typing import NamedTuple 5 | from typing import Optional 6 | 7 | from osgeo import gdal 8 | gdal.UseExceptions() 9 | 10 | 11 | class Translation(NamedTuple): 12 | x: float = 0.0 13 | y: float = 0.0 14 | z: float = 0.0 15 | 16 | 17 | class Rotation(NamedTuple): 18 | x: float = 0.0 19 | y: float = 0.0 20 | z: float = 0.0 21 | 22 | 23 | def manipulate_raster( 24 | dem_aoi: str, 25 | *, 26 | rotation: Optional[Rotation] = None, 27 | translation: Optional[Translation] = None, 28 | ) -> str: 29 | 30 | if rotation is None and translation is None: 31 | warnings.warn( 32 | UserWarning( 33 | "manipulate_raster was called but with no rotation or " 34 | "translation option specified." 35 | ) 36 | ) 37 | return dem_aoi 38 | temp_location = os.path.dirname(dem_aoi) 39 | output_file = tempfile.NamedTemporaryFile( 40 | dir=temp_location, suffix=".tif", delete=False 41 | ).name 42 | raise NotImplementedError 43 | 44 | 45 | def dem_aoi(tmp_location: str, dem_foundation: str, aoi_shapefile: str) -> str: 46 | os.makedirs(tmp_location, exist_ok=True) 47 | output_file = tempfile.NamedTemporaryFile( 48 | dir=tmp_location, suffix=".tif", delete=False 49 | ).name 50 | gdal.Warp( 51 | destNameOrDestDS=output_file, 52 | srcDSOrSrcDSTab=dem_foundation, 53 | cutlineDSName=aoi_shapefile, 54 | cropToCutline=True, 55 | copyMetadata=True, 56 | dstNodata=0, 57 | ) 58 | return output_file 59 | -------------------------------------------------------------------------------- /tests/test_registration.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import itertools 3 | import math 4 | import os 5 | import pathlib 6 | 7 | import codem 8 | import numpy as np 9 | import pytest 10 | from osgeo import gdal 11 | gdal.UseExceptions() 12 | from point_cloud import manipulate_pc 13 | from point_cloud import pc_pipeline 14 | from point_cloud import Rotation 15 | from point_cloud import Translation 16 | from raster import dem_aoi 17 | 18 | 19 | aoi_shapefile = os.path.abspath("tests/data/aoi_shapefile/aoi.shp") 20 | dem_foundation = os.path.abspath("tests/data/dem.tif") 21 | pc_foundation = os.path.abspath("tests/data/pc.laz") 22 | pipeline_foundation = os.path.abspath("tests/data/pipeline.json") 23 | temporary_directory = os.path.abspath("tests/data/temporary") 24 | 25 | 26 | def make_pc_file(aoi_temp_directory: str = temporary_directory) -> str: 27 | return pc_pipeline(aoi_temp_directory, pc_foundation, aoi_shapefile) 28 | 29 | def make_raster_aoi(aoi_temp_directory: str = temporary_directory) -> str: 30 | return dem_aoi(aoi_temp_directory, dem_foundation, aoi_shapefile) 31 | 32 | def make_pc_pipeline(aoi_temp_directory: str = temporary_directory) -> str: 33 | return pc_pipeline(aoi_temp_directory, pipeline_foundation, aoi_shapefile) 34 | 35 | pc_aoi_file = make_pc_file() 36 | raster_aoi_file = make_raster_aoi() 37 | pipeline_file = make_pc_pipeline() 38 | 39 | pc_aoi_alterations = [ 40 | pytest.param(pc_aoi_file, id="PC AOI Original"), 41 | pytest.param( 42 | manipulate_pc(pc_aoi_file, rotation=Rotation(z=2 * math.pi)), 43 | id="PC AOI Rotate 360 degrees", 44 | ), 45 | pytest.param( 46 | manipulate_pc(pc_aoi_file, translation=Translation(x=10.0)), 47 | id="PC AOI Translate x=10", 48 | ), 49 | pytest.param( 50 | manipulate_pc(pc_aoi_file, rotation=Rotation(z=math.pi)), 51 | id="PC AOI Rotate 180 degrees", 52 | ), 53 | pytest.param( 54 | manipulate_pc( 55 | pc_aoi_file, 56 | rotation=Rotation(z=math.pi / 2), 57 | translation=Translation(x=10_000.0, y=500.0), 58 | ), 59 | id="PC AOI Rotate 90 degrees and Translate x=10,000, y=500", 60 | ), 61 | ] 62 | 63 | dem_aoi_alterations = [pytest.param(raster_aoi_file, id="DEM AOI Original")] 64 | 65 | 66 | @pytest.mark.parametrize( 67 | "aoi", itertools.chain(pc_aoi_alterations, dem_aoi_alterations) 68 | ) 69 | @pytest.mark.parametrize( 70 | "foundation", 71 | [ 72 | pytest.param(pc_foundation, id="PC Foundation"), 73 | pytest.param(dem_foundation, id="DEM Foundation"), 74 | pytest.param(pipeline_foundation, id="Pipeline Foundation"), 75 | ], 76 | ) 77 | def test_registration(foundation: str, aoi: str, tmp_path: pathlib.Path) -> None: 78 | output_directory = tmp_path.resolve().as_posix() 79 | codem_run_config = codem.CodemRunConfig( 80 | foundation, aoi, OUTPUT_DIR=output_directory 81 | ) 82 | config = dataclasses.asdict(codem_run_config) 83 | 84 | assert os.path.realpath(foundation) == os.path.realpath(config["FND_FILE"]) 85 | assert os.path.realpath(aoi) == os.path.realpath(config["AOI_FILE"]) 86 | 87 | fnd_obj, aoi_obj = codem.preprocess(config) 88 | 89 | assert not fnd_obj.processed 90 | assert not aoi_obj.processed 91 | 92 | # make sure I can't set to a negative value 93 | with pytest.raises(ValueError): 94 | fnd_obj.resolution = -1 95 | 96 | # make sure I can't set to a negative non-finite value 97 | with pytest.raises(ValueError): 98 | aoi_obj.resolution = float("-inf") 99 | 100 | fnd_obj.prep() 101 | aoi_obj.prep() 102 | 103 | assert fnd_obj.processed 104 | assert aoi_obj.processed 105 | 106 | # perform dsm registration 107 | dsm_reg = codem.coarse_registration(fnd_obj, aoi_obj, config) 108 | 109 | # perform fine registration 110 | icp_reg = codem.fine_registration(fnd_obj, aoi_obj, dsm_reg, config) 111 | 112 | # apply registration 113 | reg_file = codem.apply_registration(fnd_obj, aoi_obj, icp_reg, config) 114 | 115 | assert os.path.exists(reg_file) 116 | 117 | 118 | @pytest.mark.parametrize("foundation,compliment", [(dem_foundation, raster_aoi_file)]) 119 | def test_area_or_point( 120 | foundation: str, compliment: str, tmp_path: pathlib.Path 121 | ) -> None: 122 | """Rasters can have their elevation data represented as the position in the 123 | top-left corner (RasterPixelIsArea) or in the middle of the pixel 124 | (RasterPixelIsPoint) 125 | 126 | 127 | Pixel is Area: 128 | 129 | (0) (1) 130 | (0)_|_______| 131 | | | 132 | | | 133 | (1)_|_______| 134 | 135 | Pixel is Point: 136 | 137 | (0) (1) 138 | ___|___ ___|___ 139 | | | | | | 140 | (0)-|---|---|---|---| 141 | |___|___|___|___| 142 | | | 143 | 144 | Through the CODEM registration pipeline, data is shifted such that pixel is 145 | point is the default representation. 146 | 147 | 148 | # to change a raster: 149 | $ gdal_translate 150 | -mo AREA_OR_POINT=POINT 151 | --config GTIFF_POINT_GEO_IGNORE True 152 | input_pixel_is_area.tif output_pixel_is_point.tif 153 | """ 154 | 155 | # compliment by default has AREA_OR_PIXEL=AREA 156 | compliment_base, extension = os.path.splitext(compliment) 157 | alternate = f"{compliment_base}_pixel_is_point{extension}" 158 | 159 | compliment_info = gdal.Info(compliment, format="json") 160 | compliment_pixel = compliment_info["metadata"][""]["AREA_OR_POINT"] 161 | # create the alternative compliment with the different setup 162 | alternate_pixel = "Point" if compliment_pixel.lower() == "area" else "Area" 163 | 164 | # need to set the config option accordingly 165 | gdal.SetConfigOption("GTIFF_POINT_GEO_IGNORE", "YES") 166 | gdal.Translate( 167 | alternate, compliment, options=f"-mo AREA_OR_POINT={alternate_pixel.upper()}" 168 | ) 169 | gdal.SetConfigOption("GTIFF_POINT_GEO_IGNORE", "NO") 170 | 171 | alternate_info = gdal.Info(alternate, format="json") 172 | alternate_pixel = alternate_info["metadata"][""]["AREA_OR_POINT"] 173 | 174 | # make sure metadata actually is different 175 | assert compliment_pixel.lower() != alternate_pixel.lower() 176 | 177 | # compare bounds with gdal looking at corners 178 | assert compliment_info["cornerCoordinates"] != alternate_info["cornerCoordinates"] 179 | 180 | # now we run two registration processes alongside checking if both AOIs are equivalent... 181 | output_directory = tmp_path.resolve().as_posix() 182 | compliment_config = dataclasses.asdict( 183 | codem.CodemRunConfig(foundation, compliment, OUTPUT_DIR=output_directory) 184 | ) 185 | alternate_config = dataclasses.asdict( 186 | codem.CodemRunConfig(foundation, alternate, OUTPUT_DIR=output_directory) 187 | ) 188 | 189 | foundation_comp, compliment_obj = codem.preprocess(compliment_config) 190 | foundation_alt, alternate_obj = codem.preprocess(alternate_config) 191 | 192 | # resolutions should be the same 193 | assert math.isclose(compliment_obj.resolution, alternate_obj.resolution) 194 | 195 | # transforms should have different offsets 196 | assert compliment_obj.transform.xoff != alternate_obj.transform.xoff # type: ignore 197 | assert compliment_obj.transform.yoff != alternate_obj.transform.yoff # type: ignore 198 | 199 | # let's prep 200 | foundation_comp.prep() 201 | foundation_alt.prep() 202 | compliment_obj.prep() 203 | alternate_obj.prep() 204 | 205 | # make sure we actually have the correct attribute set 206 | assert compliment_obj.area_or_point.lower() != alternate_obj.area_or_point.lower() 207 | 208 | # GeoData.point_cloud compensates for this offset... 209 | assert np.allclose(compliment_obj.point_cloud, alternate_obj.point_cloud) 210 | 211 | dsm_reg_compliment = codem.coarse_registration( 212 | foundation_comp, compliment_obj, compliment_config 213 | ) 214 | 215 | dsm_reg_alternate = codem.coarse_registration( 216 | foundation_alt, alternate_obj, alternate_config 217 | ) 218 | 219 | # we should have the same number of putative matches 220 | assert len(dsm_reg_compliment.putative_matches) == len( 221 | dsm_reg_alternate.putative_matches 222 | ) 223 | 224 | # ensure that coarse transformations are different by resolution 225 | if compliment_obj.area_or_point.lower() == "area": 226 | transform_to_offset = dsm_reg_compliment.transformation 227 | transform_to_fix = dsm_reg_alternate.transformation 228 | else: 229 | transform_to_offset = dsm_reg_alternate.transformation 230 | transform_to_fix = dsm_reg_compliment.transformation 231 | 232 | # resolution should be the same 233 | pos_offset = transform_to_offset[0:2, -1] + np.array( 234 | [compliment_obj.resolution, -compliment_obj.resolution] 235 | ) 236 | assert np.allclose(pos_offset, transform_to_fix[0:2, -1]) 237 | 238 | # perform fine registration 239 | icp_reg_compliment = codem.fine_registration( 240 | foundation_comp, compliment_obj, dsm_reg_compliment, compliment_config 241 | ) 242 | icp_reg_alternate = codem.fine_registration( 243 | foundation_alt, alternate_obj, dsm_reg_alternate, alternate_config 244 | ) 245 | 246 | # ensure that coarse transformations are different by resolution 247 | if compliment_obj.area_or_point.lower() == "area": 248 | transform_to_offset = icp_reg_compliment.transformation 249 | transform_to_fix = icp_reg_alternate.transformation 250 | else: 251 | transform_to_offset = icp_reg_alternate.transformation 252 | transform_to_fix = icp_reg_compliment.transformation 253 | 254 | pos_offset = transform_to_offset[0:2, -1] + np.array( 255 | [compliment_obj.resolution, -compliment_obj.resolution] 256 | ) 257 | assert np.allclose(pos_offset, transform_to_fix[0:2, -1]) 258 | 259 | # do the registration 260 | registered_compliment = codem.apply_registration( 261 | foundation_comp, compliment_obj, icp_reg_compliment, compliment_config 262 | ) 263 | registered_alternate = codem.apply_registration( 264 | foundation_alt, alternate_obj, icp_reg_alternate, alternate_config 265 | ) 266 | 267 | registered_compliment_info = gdal.Info(registered_compliment, format="json") 268 | registered_alternate_info = gdal.Info(registered_alternate, format="json") 269 | 270 | # bounds between the two datasets should be different 271 | assert ( 272 | registered_compliment_info["cornerCoordinates"] 273 | != registered_alternate_info["cornerCoordinates"] 274 | ) 275 | 276 | # metadata should be preserved between input and registered output 277 | assert ( 278 | registered_compliment_info["metadata"][""]["AREA_OR_POINT"] 279 | == compliment_info["metadata"][""]["AREA_OR_POINT"] 280 | ) 281 | assert ( 282 | registered_alternate_info["metadata"][""]["AREA_OR_POINT"] 283 | == alternate_info["metadata"][""]["AREA_OR_POINT"] 284 | ) 285 | -------------------------------------------------------------------------------- /tests/vcd/make-laz.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # 4 | 5 | bounds="([-10429500, -10429000], [5081800, 5082300])" 6 | 7 | pdal translate https://s3-us-west-2.amazonaws.com/usgs-lidar-public/IA_SouthCentral_1_2020/ept.json \ 8 | after.copc.laz \ 9 | --readers.ept.bounds="$bounds" 10 | 11 | pdal translate https://s3-us-west-2.amazonaws.com/usgs-lidar-public/IA_FullState/ept.json \ 12 | before.copc.laz \ 13 | --readers.ept.bounds="$bounds" 14 | --------------------------------------------------------------------------------