├── .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 | [](https://github.com/seedlit/dsm2dtm/blob/main/LICENSE)
7 | [](https://anaconda.org/conda-forge/dsm2dtm)
8 | [](https://anaconda.org/conda-forge/dsm2dtm)
9 | [](https://github.com/seedlit/dsm2dtm/graphs/contributors)
10 | 
11 | [](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 | 
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 | 
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 |
--------------------------------------------------------------------------------