├── .github └── workflows │ └── python-package-conda.yml ├── .gitignore ├── LICENSE ├── README.md ├── conda-recipe └── meta.yaml ├── data ├── logo.png ├── sample_dsm.tif └── sample_hiilside_dsm_30cm.tif ├── dsm2dtm.py ├── requirements.txt ├── results ├── example2_dsm2dtm_hillside.png └── result.png ├── setup.py └── tests.py /.github/workflows/python-package-conda.yml: -------------------------------------------------------------------------------- 1 | name: Python Package using Conda 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build-linux: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | max-parallel: 5 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Python 3.6 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: 3.6 17 | - name: Add conda to system path 18 | run: | 19 | # $CONDA is an environment variable pointing to the root of the miniconda directory 20 | echo $CONDA/bin >> $GITHUB_PATH 21 | - name: Install dependencies 22 | run: | 23 | conda create -n dsm2dtm_env python=3.6 24 | conda info 25 | $CONDA/bin/activate dsm2dtm_env 26 | conda config --prepend channels conda-forge 27 | conda install conda-verify 28 | conda install -c conda-forge conda-build 29 | - name: Build conda package 30 | run: | 31 | # REF:https://github.com/conda/conda-build/issues/532#issuecomment-282032259 32 | conda build conda-recipe -c conda-forge 33 | - name: Install the built conda package 34 | run: | 35 | conda install dsm2dtm -c $CONDA/conda-bld/ -c conda-forge 36 | - name: Lint with flake8 37 | run: | 38 | #conda install flake8 39 | ## stop the build if there are Python syntax errors or undefined names 40 | #flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 41 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 42 | #flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 43 | # - name: Test with pytest 44 | # run: | 45 | # conda install pytest 46 | # pytest 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mac specific 124 | */.DS_Store 125 | .DS_Store 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 seedlit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dsm2dtm 2 | 3 | 4 | This repo generates DTM (Digital Terrain Model) from DSM (Digital Surface Model). 5 | 6 | [![GitHub](https://img.shields.io/github/license/seedlit/dsm2dtm?style=flat-square)](https://github.com/seedlit/dsm2dtm/blob/main/LICENSE) 7 | [![Anaconda-Server Badge](https://anaconda.org/conda-forge/dsm2dtm/badges/downloads.svg)](https://anaconda.org/conda-forge/dsm2dtm) 8 | [![Conda Version](https://img.shields.io/conda/vn/conda-forge/dsm2dtm.svg)](https://anaconda.org/conda-forge/dsm2dtm) 9 | [![GitHub contributors](https://img.shields.io/github/contributors/seedlit/dsm2dtm?style=flat-square)](https://github.com/seedlit/dsm2dtm/graphs/contributors) 10 | ![Python Version Supported](https://img.shields.io/badge/python-3.5%2B-blue) 11 | [![Anaconda-Server Badge](https://anaconda.org/conda-forge/dsm2dtm/badges/platforms.svg)](https://anaconda.org/conda-forge/dsm2dtm) 12 | 13 | ## Installation 14 | 15 | **Note**: We are unable to install Saga as part of the dependency, as it is not avilable on PyPI or conda.
16 | To install saga_cmd - `sudo apt update; sudo apt install saga` 17 | 18 | ### From Conda: 19 | ```bash 20 | conda install -c conda-forge dsm2dtm 21 | ``` 22 | These step are for Linux. This will differ a bit for MacOS and windows. 23 | ### From Source 24 | 25 | ```bash 26 | # Step 1: Clone the repo 27 | % git clone https://github.com/seedlit/dsm2dtm.git 28 | # Step 2: Move in the folder 29 | % cd dsm2dtm 30 | # Step 3: Create a virtual environment 31 | % python3 -m venv venv 32 | # Step 4: Activate the environment 33 | % source venv/bin/activate 34 | # Step 5: Install requirements 35 | % pip install -r requirements.txt 36 | # Step 6: Install saga_cmd 37 | % sudo apt update 38 | % sudo apt install saga 39 | ``` 40 | 41 | ## Usage 42 | Run the script dsm2dtm.py and pass the dsm path as argument. 43 | ```bash 44 | python dsm2dtm.py --dsm data/sample_dsm.tif 45 | ``` 46 | 47 | ### Example1: Input DSM and generated DTM over a flat terrain 48 | ![example](./results/result.png) 49 | 50 | ### Example2: Input DSM, generated DTM, and groundtruth DTM (Lidar derived) over a hillside terrain 51 | DSM was derived from [this point cloud data](https://cloud.rockrobotic.com/share/f42b5b69-c87c-4433-94f8-4bc0d8eaee90#lidar) 52 | ![example](./results/example2_dsm2dtm_hillside.png) 53 | 54 | 55 | ## TODO 56 | - Add tests and coverage 57 | - Add poetry (with separate dependencies for dev: black, isort, pyest, etc.) 58 | - Add pre-commit hooks (isort, black, mypy) 59 | - Add documentation 60 | - Move test file(s) to remote server OR use gitlfs OR use fake-geo-images 61 | - Reduce I/O by passing rasterio object instead of raster path 62 | - Add exception handling 63 | - use [SAGA python API](https://saga-gis.sourceforge.io/saga_api_python/index.html) instead of command line ineterface (saga_cmd) 64 | - upsample generated DTM if the source DSM was downsampled 65 | - setup docker-compose (and maybe expose as FastAPI app?) -------------------------------------------------------------------------------- /conda-recipe/meta.yaml: -------------------------------------------------------------------------------- 1 | {% set name = "dsm2dtm" %} 2 | {% set version = "0.1.1" %} 3 | 4 | package: 5 | name: "{{ name|lower }}" 6 | version: "{{ version }}" 7 | 8 | # source: 9 | # git_url: https://github.com/seedlit/{{ name }}.git 10 | source: 11 | # Relative path to the parent directory. 12 | # ref: https://stackoverflow.com/a/61810510 13 | path: .. 14 | 15 | build: 16 | number: 0 17 | script: "{{ PYTHON }} -m pip install . --no-deps --ignore-installed -vv " 18 | 19 | requirements: 20 | build: 21 | - git 22 | - python 23 | - pip 24 | - setuptools 25 | host: 26 | - pip 27 | - python 28 | run: 29 | - python 30 | - gdal >=3.0.* 31 | - rasterio >=1.2.* 32 | - numpy >=1.20.* 33 | 34 | test: 35 | imports: 36 | - dsm2dtm 37 | 38 | about: 39 | home: https://github.com/seedlit/dsm2dtm 40 | license: BSD 41 | license_family: BSD 42 | summary: Generates DTM (Digital Terrain Model) from DSM (Digital Surface Model). 43 | -------------------------------------------------------------------------------- /data/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seedlit/dsm2dtm/f95886b5f10a08387416e0e6cd270b6059bed5fa/data/logo.png -------------------------------------------------------------------------------- /data/sample_dsm.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seedlit/dsm2dtm/f95886b5f10a08387416e0e6cd270b6059bed5fa/data/sample_dsm.tif -------------------------------------------------------------------------------- /data/sample_hiilside_dsm_30cm.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seedlit/dsm2dtm/f95886b5f10a08387416e0e6cd270b6059bed5fa/data/sample_hiilside_dsm_30cm.tif -------------------------------------------------------------------------------- /dsm2dtm.py: -------------------------------------------------------------------------------- 1 | """ 2 | dsm2dtm - Generate DTM (Digital Terrain Model) from DSM (Digital Surface Model) 3 | Author: Naman Jain 4 | naman.jain@btech2015.iitgn.ac.in 5 | www.namanji.wixsite.com/naman/ 6 | """ 7 | 8 | import os 9 | import numpy as np 10 | import rasterio 11 | import argparse 12 | 13 | try: 14 | import gdal 15 | except: 16 | from osgeo import gdal 17 | 18 | 19 | def downsample_raster(in_path, out_path, downsampling_factor): 20 | gdal_raster = gdal.Open(in_path) 21 | width, height = gdal_raster.RasterXSize, gdal_raster.RasterYSize 22 | gdal.Translate( 23 | out_path, 24 | in_path, 25 | width=int((width // downsampling_factor)), 26 | height=int((height // downsampling_factor)), 27 | outputType=gdal.GDT_Float32, 28 | ) 29 | 30 | 31 | def upsample_raster(in_path, out_path, target_height, target_width): 32 | gdal.Translate( 33 | out_path, 34 | in_path, 35 | width=target_width, 36 | height=target_height, 37 | resampleAlg="bilinear", 38 | outputType=gdal.GDT_Float32, 39 | ) 40 | 41 | 42 | def generate_slope_raster(in_path, out_path): 43 | """ 44 | Generates a slope raster from the input DEM raster. 45 | Input: 46 | in_path: {string} path to the DEM raster 47 | Output: 48 | out_path: {string} path to the generated slope image 49 | """ 50 | cmd = "gdaldem slope -alg ZevenbergenThorne {} {}".format(in_path, out_path) 51 | os.system(cmd) 52 | 53 | 54 | def get_mean(raster_path, ignore_value=-9999.0): 55 | np_raster = np.array(gdal.Open(raster_path).ReadAsArray()) 56 | return np_raster[np_raster != ignore_value].mean() 57 | 58 | 59 | def extract_dtm(dsm_path, ground_dem_path, non_ground_dem_path, radius, terrain_slope): 60 | """ 61 | Generates a ground DEM and non-ground DEM raster from the input DSM raster. 62 | Input: 63 | dsm_path: {string} path to the DSM raster 64 | radius: {int} Search radius of kernel in cells. 65 | terrain_slope: {float} average slope of the input terrain 66 | Output: 67 | ground_dem_path: {string} path to the generated ground DEM raster 68 | non_ground_dem_path: {string} path to the generated non-ground DEM raster 69 | """ 70 | cmd = "saga_cmd grid_filter 7 -INPUT {} -RADIUS {} -TERRAINSLOPE {} -GROUND {} -NONGROUND {}".format( 71 | dsm_path, radius, terrain_slope, ground_dem_path, non_ground_dem_path 72 | ) 73 | os.system(cmd) 74 | 75 | 76 | def remove_noise(ground_dem_path, out_path, ignore_value=-99999.0): 77 | """ 78 | Removes noise (high elevation data points like roofs, etc.) from the ground DEM raster. 79 | Replaces values in those pixels with No data Value (-99999.0) 80 | Input: 81 | ground_dem_path: {string} path to the generated ground DEM raster 82 | no_data_value: {float} replacing value in the ground raster (to be treated as No Data Value) 83 | Output: 84 | out_path: {string} path to the filtered ground DEM raster 85 | """ 86 | ground_np = np.array(gdal.Open(ground_dem_path).ReadAsArray()) 87 | std = ground_np[ground_np != ignore_value].std() 88 | mean = ground_np[ground_np != ignore_value].mean() 89 | threshold_value = mean + 1.5 * std 90 | ground_np[ground_np >= threshold_value] = -99999.0 91 | save_array_as_geotif(ground_np, ground_dem_path, out_path) 92 | 93 | 94 | def save_array_as_geotif(array, source_tif_path, out_path): 95 | """ 96 | Generates a geotiff raster from the input numpy array (height * width * depth) 97 | Input: 98 | array: {numpy array} numpy array to be saved as geotiff 99 | source_tif_path: {string} path to the geotiff from which projection and geotransformation information will be extracted. 100 | Output: 101 | out_path: {string} path to the generated Geotiff raster 102 | """ 103 | if len(array.shape) > 2: 104 | height, width, depth = array.shape 105 | else: 106 | height, width = array.shape 107 | depth = 1 108 | source_tif = gdal.Open(source_tif_path) 109 | driver = gdal.GetDriverByName("GTiff") 110 | dataset = driver.Create(out_path, width, height, depth, gdal.GDT_Float32) 111 | if depth != 1: 112 | for i in range(depth): 113 | dataset.GetRasterBand(i + 1).WriteArray(array[:, :, i]) 114 | else: 115 | dataset.GetRasterBand(1).WriteArray(array) 116 | geotrans = source_tif.GetGeoTransform() 117 | proj = source_tif.GetProjection() 118 | dataset.SetGeoTransform(geotrans) 119 | dataset.SetProjection(proj) 120 | dataset.FlushCache() 121 | dataset = None 122 | 123 | 124 | def sdat_to_gtiff(sdat_raster_path, out_gtiff_path): 125 | gdal.Translate( 126 | out_gtiff_path, 127 | sdat_raster_path, 128 | format="GTiff", 129 | ) 130 | 131 | 132 | def close_gaps(in_path, out_path, threshold=0.1): 133 | """ 134 | Interpolates the holes (no data value) in the input raster. 135 | Input: 136 | in_path: {string} path to the input raster with holes 137 | threshold: {float} Tension Threshold 138 | Output: 139 | out_path: {string} path to the generated raster with closed holes. 140 | """ 141 | cmd = "saga_cmd grid_tools 7 -INPUT {} -THRESHOLD {} -RESULT {}".format( 142 | in_path, threshold, out_path 143 | ) 144 | os.system(cmd) 145 | 146 | 147 | def smoothen_raster(in_path, out_path, radius=2): 148 | """ 149 | Applies gaussian filter to the input raster. 150 | Input: 151 | in_path: {string} path to the input raster 152 | radius: {int} kernel radius to be used for smoothing 153 | Output: 154 | out_path: {string} path to the generated smoothened raster 155 | """ 156 | cmd = "saga_cmd grid_filter 1 -INPUT {} -RESULT {} -KERNEL_TYPE 0 -KERNEL_RADIUS {}".format( 157 | in_path, out_path, radius 158 | ) 159 | os.system(cmd) 160 | 161 | 162 | def subtract_rasters(rasterA_path, rasterB_path, out_path, no_data_value=-99999.0): 163 | cmd = 'gdal_calc.py -A {} -B {} --outfile {} --NoDataValue={} --calc="A-B"'.format( 164 | rasterA_path, rasterB_path, out_path, no_data_value 165 | ) 166 | os.system(cmd) 167 | 168 | 169 | def replace_values( 170 | rasterA_path, rasterB_path, out_path, no_data_value=-99999.0, threshold=0.98 171 | ): 172 | """ 173 | Replaces values in input rasterA with no_data_value where cell value >= threshold in rasterB 174 | Input: 175 | rasterA_path: {string} path to the input rasterA 176 | rasterB_path: {string} path to the input rasterB 177 | Output: 178 | out_path: {string} path to the generated raster 179 | """ 180 | cmd = 'gdal_calc.py -A {} --NoDataValue={} -B {} --outfile {} --calc="{}*(B>={}) + (A)*(B<{})"'.format( 181 | rasterA_path, 182 | no_data_value, 183 | rasterB_path, 184 | out_path, 185 | no_data_value, 186 | threshold, 187 | threshold, 188 | ) 189 | os.system(cmd) 190 | 191 | 192 | def expand_holes_in_raster( 193 | in_path, search_window=7, no_data_value=-99999.0, threshold=50 194 | ): 195 | """ 196 | Expands holes (cells with no_data_value) in the input raster. 197 | Input: 198 | in_path: {string} path to the input raster 199 | search_window: {int} kernel size to be used as window 200 | threshold: {float} threshold on percentage of cells with no_data_value 201 | Output: 202 | np_raster: {numpy array} Returns the modified input raster's array 203 | """ 204 | np_raster = np.array(gdal.Open(in_path).ReadAsArray()) 205 | height, width = np_raster.shape[0], np_raster.shape[1] 206 | for i in range(int((search_window - 1) / 2), width, 1): 207 | for j in range(int((search_window - 1) / 2), height, 1): 208 | window = np_raster[ 209 | int(i - (search_window - 1) / 2) : int(i - (search_window - 1) / 2) 210 | + search_window, 211 | int(j - (search_window - 1) / 2) : int(j - (search_window - 1) / 2) 212 | + search_window, 213 | ] 214 | if ( 215 | np.count_nonzero(window == no_data_value) 216 | >= (threshold * search_window ** 2) / 100 217 | ): 218 | try: 219 | np_raster[i, j] = no_data_value 220 | except: 221 | pass 222 | return np_raster 223 | 224 | 225 | def get_raster_crs(raster_path): 226 | """ 227 | Returns the CRS (Coordinate Reference System) of the raster 228 | Input: 229 | raster_path: {string} path to the source tif image 230 | """ 231 | raster = rasterio.open(raster_path) 232 | return raster.crs 233 | 234 | 235 | def get_raster_resolution(raster_path): 236 | raster = gdal.Open(raster_path) 237 | raster_geotrans = raster.GetGeoTransform() 238 | x_res = raster_geotrans[1] 239 | y_res = -raster_geotrans[5] 240 | return x_res, y_res 241 | 242 | 243 | def get_res_and_downsample(dsm_path, temp_dir): 244 | # check DSM resolution. Downsample if DSM is of very high resolution to save processing time. 245 | x_res, y_res = get_raster_resolution(dsm_path) # resolutions are in meters 246 | dsm_name = dsm_path.split("/")[-1].split(".")[0] 247 | dsm_crs = get_raster_crs(dsm_path) 248 | if dsm_crs != 4326: 249 | if x_res < 0.3 or y_res < 0.3: 250 | target_res = 0.3 # downsample to this resolution (in meters) 251 | downsampling_factor = target_res / gdal.Open(dsm_path).GetGeoTransform()[1] 252 | downsampled_dsm_path = os.path.join(temp_dir, dsm_name + "_ds.tif") 253 | # Dowmsampling DSM 254 | downsample_raster(dsm_path, downsampled_dsm_path, downsampling_factor) 255 | dsm_path = downsampled_dsm_path 256 | else: 257 | if x_res < 2.514e-06 or y_res < 2.514e-06: 258 | target_res = 2.514e-06 # downsample to this resolution (in degrees) 259 | downsampling_factor = target_res / gdal.Open(dsm_path).GetGeoTransform()[1] 260 | downsampled_dsm_path = os.path.join(temp_dir, dsm_name + "_ds.tif") 261 | # Dowmsampling DSM 262 | downsample_raster(dsm_path, downsampled_dsm_path, downsampling_factor) 263 | dsm_path = downsampled_dsm_path 264 | return dsm_path 265 | 266 | 267 | def get_updated_params(dsm_path, search_radius, smoothen_radius): 268 | # search_radius and smoothen_radius are set wrt to 30cm DSM 269 | # returns updated parameters if DSM is of coarser resolution 270 | x_res, y_res = get_raster_resolution(dsm_path) # resolutions are in meters 271 | dsm_crs = get_raster_crs(dsm_path) 272 | if dsm_crs != 4326: 273 | if x_res > 0.3 or y_res > 0.3: 274 | search_radius = int((min(x_res, y_res) * search_radius) / 0.3) 275 | smoothen_radius = int((min(x_res, y_res) * smoothen_radius) / 0.3) 276 | else: 277 | if x_res > 2.514e-06 or y_res > 2.514e-06: 278 | search_radius = int((min(x_res, y_res) * search_radius) / 2.514e-06) 279 | smoothen_radius = int((min(x_res, y_res) * smoothen_radius) / 2.514e-06) 280 | return search_radius, smoothen_radius 281 | 282 | 283 | def main( 284 | dsm_path, 285 | out_dir, 286 | search_radius=40, 287 | smoothen_radius=45, 288 | dsm_replace_threshold_val=0.98, 289 | ): 290 | # master function that calls all other functions 291 | os.makedirs(out_dir, exist_ok=True) 292 | temp_dir = os.path.join(out_dir, "temp_files") 293 | os.makedirs(temp_dir, exist_ok=True) 294 | dsm_path = get_res_and_downsample(dsm_path, temp_dir) 295 | # get updated params wrt to DSM resolution 296 | search_radius, smoothen_radius = get_updated_params( 297 | dsm_path, search_radius, smoothen_radius 298 | ) 299 | # Generate DTM 300 | # STEP 1: Generate slope raster from dsm to get average slope value 301 | dsm_name = dsm_path.split("/")[-1].split(".")[0] 302 | dsm_slp_path = os.path.join(temp_dir, dsm_name + "_slp.tif") 303 | generate_slope_raster(dsm_path, dsm_slp_path) 304 | avg_slp = int(get_mean(dsm_slp_path)) 305 | # STEP 2: Split DSM into ground and non-ground surface rasters 306 | ground_dem_path = os.path.join(temp_dir, dsm_name + "_ground.sdat") 307 | non_ground_dem_path = os.path.join(temp_dir, dsm_name + "_non_ground.sdat") 308 | extract_dtm( 309 | dsm_path, 310 | ground_dem_path, 311 | non_ground_dem_path, 312 | search_radius, 313 | avg_slp, 314 | ) 315 | # STEP 3: Applying Gaussian Filter on the generated ground raster (parameters: radius = 45, mode = Circle) 316 | smoothened_ground_path = os.path.join(temp_dir, dsm_name + "_ground_smth.sdat") 317 | smoothen_raster(ground_dem_path, smoothened_ground_path, smoothen_radius) 318 | # STEP 4: Generating a difference raster (ground DEM - smoothened ground DEM) 319 | diff_raster_path = os.path.join(temp_dir, dsm_name + "_ground_diff.sdat") 320 | subtract_rasters(ground_dem_path, smoothened_ground_path, diff_raster_path) 321 | # STEP 5: Thresholding on the difference raster to replace values in Ground DEM by no-data values (threshold = 0.98) 322 | thresholded_ground_path = os.path.join( 323 | temp_dir, dsm_name + "_ground_thresholded.sdat" 324 | ) 325 | replace_values( 326 | ground_dem_path, 327 | diff_raster_path, 328 | thresholded_ground_path, 329 | threshold=dsm_replace_threshold_val, 330 | ) 331 | # STEP 6: Removing noisy spikes from the generated DTM 332 | ground_dem_filtered_path = os.path.join(temp_dir, dsm_name + "_ground_filtered.tif") 333 | remove_noise(thresholded_ground_path, ground_dem_filtered_path) 334 | # STEP 7: Expanding holes in the thresholded ground raster 335 | bigger_holes_ground_path = os.path.join( 336 | temp_dir, dsm_name + "_ground_bigger_holes.sdat" 337 | ) 338 | temp = expand_holes_in_raster(ground_dem_filtered_path) 339 | save_array_as_geotif(temp, ground_dem_filtered_path, bigger_holes_ground_path) 340 | # STEP 8: Close gaps in the DTM 341 | dtm_path = os.path.join(temp_dir, dsm_name + "_dtm.sdat") 342 | close_gaps(bigger_holes_ground_path, dtm_path) 343 | # STEP 9: Convert to GeoTiff 344 | dtm_array = gdal.Open(dtm_path).ReadAsArray() 345 | dtm_tif_path = os.path.join(out_dir, dsm_name + "_dtm.tif") 346 | # save_array_as_geotif(dtm_array, dsm_path, dtm_tif_path) 347 | sdat_to_gtiff(dtm_path, dtm_tif_path) 348 | return dtm_tif_path 349 | 350 | 351 | # ----------------------------------------------------------------------------------------------------- 352 | if __name__ == "__main__": 353 | 354 | parser = argparse.ArgumentParser(description="Generate DTM from DSM") 355 | parser.add_argument("--dsm", help="dsm path string") 356 | args = parser.parse_args() 357 | dsm_path = args.dsm 358 | out_dir = "generated_dtm" 359 | dtm_path = main(dsm_path, out_dir) 360 | print("######### DTM generated at: ", dtm_path) 361 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy==1.20.3 2 | GDAL==3.0.4 3 | rasterio==1.2.5 -------------------------------------------------------------------------------- /results/example2_dsm2dtm_hillside.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seedlit/dsm2dtm/f95886b5f10a08387416e0e6cd270b6059bed5fa/results/example2_dsm2dtm_hillside.png -------------------------------------------------------------------------------- /results/result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seedlit/dsm2dtm/f95886b5f10a08387416e0e6cd270b6059bed5fa/results/result.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="dsm2dtm", 8 | version="0.1.0", 9 | author="Naman Jain", 10 | author_email="naman.jain@btech2015.iitgn.ac.in", 11 | description="Generate DTM (Digital Terrain Model) from DSM (Digital Surface Model)", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/seedlit/dsm2dtm", 15 | py_modules=['dsm2dtm'], # Sol for single file package: https://docs.python.org/3/distutils/introduction.html#a-simple-example 16 | include_package_data=True, 17 | classifiers=[ 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.6", 20 | "Programming Language :: Python :: 3.7", 21 | "Programming Language :: Python :: 3.8", 22 | "Programming Language :: Python :: 3.9", 23 | "License :: OSI Approved :: MIT License", 24 | "Operating System :: OS Independent", 25 | "Topic :: Scientific/Engineering", 26 | "Intended Audience :: Developers", 27 | "Intended Audience :: Education", 28 | "Intended Audience :: Science/Research", 29 | "Development Status :: 4 - Beta", 30 | ], 31 | python_requires=">=3.6", 32 | install_requires=[ 33 | "numpy>=1.20.3", 34 | "GDAL>=3.0.4", 35 | "rasterio>=1.2.5", 36 | ], 37 | ) -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import dsm2dtm 3 | import shutil 4 | 5 | try: 6 | import gdal 7 | except: 8 | from osgeo import gdal 9 | 10 | 11 | if __name__ == "__main__": 12 | 13 | dsm_path = "data/sample_dsm.tif" 14 | out_dir = "test_results" 15 | 16 | dsm_name = dsm_path.split("/")[-1].split(".")[0] 17 | os.makedirs(out_dir, exist_ok=True) 18 | temp_dir = os.path.join(out_dir, "temp_files") 19 | os.makedirs(temp_dir, exist_ok=True) 20 | 21 | # test each function 22 | dsm_path = dsm2dtm.get_res_and_downsample(dsm_path, temp_dir) 23 | assert dsm_path == os.path.join(temp_dir, "{}_ds.tif".format(dsm_name)) 24 | assert dsm2dtm.get_updated_params(dsm_path, 40, 45) == (40, 45) 25 | 26 | dsm_slp_path = os.path.join(temp_dir, dsm_name + "_slp.tif") 27 | dsm2dtm.generate_slope_raster(dsm_path, dsm_slp_path) 28 | assert os.path.isfile(dsm_slp_path) 29 | assert int(dsm2dtm.get_mean(dsm_slp_path)) == 89 30 | 31 | ground_dem_path = os.path.join(temp_dir, dsm_name + "_ground.sdat") 32 | non_ground_dem_path = os.path.join(temp_dir, dsm_name + "_non_ground.sdat") 33 | dsm2dtm.extract_dtm(dsm_path, ground_dem_path, non_ground_dem_path, 40, 89) 34 | assert os.path.isfile(ground_dem_path) 35 | assert os.path.isfile(non_ground_dem_path) 36 | 37 | smoothened_ground_path = os.path.join(temp_dir, dsm_name + "_ground_smth.sdat") 38 | dsm2dtm.smoothen_raster(ground_dem_path, smoothened_ground_path, 45) 39 | assert os.path.isfile(smoothened_ground_path) 40 | 41 | diff_raster_path = os.path.join(temp_dir, dsm_name + "_ground_diff.sdat") 42 | dsm2dtm.subtract_rasters(ground_dem_path, smoothened_ground_path, diff_raster_path) 43 | assert os.path.isfile(diff_raster_path) 44 | 45 | thresholded_ground_path = os.path.join( 46 | temp_dir, dsm_name + "_ground_thresholded.sdat" 47 | ) 48 | dsm2dtm.replace_values( 49 | ground_dem_path, diff_raster_path, thresholded_ground_path, 0.98 50 | ) 51 | assert os.path.isfile(thresholded_ground_path) 52 | 53 | ground_dem_filtered_path = os.path.join(temp_dir, dsm_name + "_ground_filtered.tif") 54 | dsm2dtm.remove_noise(thresholded_ground_path, ground_dem_filtered_path) 55 | assert os.path.isfile(ground_dem_filtered_path) 56 | 57 | bigger_holes_ground_path = os.path.join( 58 | temp_dir, dsm_name + "_ground_bigger_holes.sdat" 59 | ) 60 | temp_array = dsm2dtm.expand_holes_in_raster(ground_dem_filtered_path) 61 | assert temp_array.shape == (286, 315) 62 | 63 | dsm2dtm.save_array_as_geotif( 64 | temp_array, ground_dem_filtered_path, bigger_holes_ground_path 65 | ) 66 | assert os.path.isfile(bigger_holes_ground_path) 67 | 68 | dtm_path = os.path.join(temp_dir, dsm_name + "_dtm.sdat") 69 | dsm2dtm.close_gaps(bigger_holes_ground_path, dtm_path) 70 | assert os.path.isfile(dtm_path) 71 | 72 | dtm_tif_path = os.path.join(out_dir, dsm_name + "_dtm.tif") 73 | dsm2dtm.sdat_to_gtiff(dtm_path, dtm_tif_path) 74 | assert os.path.isfile(dtm_tif_path) 75 | 76 | dtm_array = gdal.Open(dtm_tif_path).ReadAsArray() 77 | assert dtm_array.shape == (286, 315) 78 | assert dsm2dtm.get_raster_resolution(dtm_tif_path) == (2.5193e-06, 2.5193e-06) 79 | assert dsm2dtm.get_raster_crs(dtm_tif_path) == 4326 80 | 81 | print("All tests passed!") 82 | shutil.rmtree(out_dir) 83 | --------------------------------------------------------------------------------