├── .gitignore ├── doc └── scheme.jpg ├── data └── input │ └── srtm_54_07_CROP_360.tif ├── README.md └── DTM2MESH.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | -------------------------------------------------------------------------------- /doc/scheme.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonathanlurie/DTM2MESH/HEAD/doc/scheme.jpg -------------------------------------------------------------------------------- /data/input/srtm_54_07_CROP_360.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonathanlurie/DTM2MESH/HEAD/data/input/srtm_54_07_CROP_360.tif -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DTM2MESH 2 | Stands for Digital Terrain Model To 3D Mesh, coded in **Python**. 3 | The mesh is exported in a [Collada](https://de.wikipedia.org/wiki/Collada_(Speicherformat)) file in order to be re-usable somewhere else. 4 | **Important Note :** This is not a Collada file viewer or any other kind of 3D mesh visualizer. 5 | **Less Important Note:** This project was made in 2 days, so be nice if you find mistakes... 6 | 7 | ![](https://raw.githubusercontent.com/jonathanlurie/DTM2MESH/master/doc/scheme.jpg) 8 | 9 | ## How to use 10 | 11 | This is a Pythonic command-line tool. 12 | 13 | **The first argument** : *-input* , is the input DTM file, usually it's a TIFF (16bit) but it should work fine with any other format as long as it's a monoband (greyscale) file, and compatible with [OpenCV](https://github.com/Itseez/opencv). **This argument is mandatory**. 14 | 15 | **The second argument** : *-output* , is the output Collada file (.dae) which is actually some kind of super-fat XML. **This argument is mandatory**. 16 | 17 | **The third argument** : *-resolution* , is the ground resolution in meter/pixel. The default resolution is 90 (SRTM compliant), thus, **this argument is optional**. 18 | *Note :* If the ground resolution is lower than expected (ex: 50 with SRTM), it will result in an exaggerated relief. The opposite (ex: 150 with SRTM ) will induce a flattening effect. 19 | 20 | *Example 1* 21 | 22 | ```shell 23 | cd DTM2MESH 24 | python DTM2MESH.py -input data/input/srtm_54_07_CROP_360.tif -output data/output/srtm_54_07_CROP_360.dae 25 | ``` 26 | 27 | *Example 2* : Big Mountains 28 | 29 | ```shell 30 | cd DTM2MESH 31 | python DTM2MESH.py -input data/input/srtm_54_07_CROP_360.tif -output data/output/srtm_54_07_CROP_360_BigMountains.dae -resolution 25 32 | ``` 33 | 34 | ## Where to find DTM images 35 | 36 | Good question. A lot of websites may provide that, coming from multiple sources. Though, A very convenient way to fetch [SRTM](https://en.wikipedia.org/wiki/Shuttle_Radar_Topography_Mission) tiles is from [Derek Watkins](https://twitter.com/dwtkns)'s [project](http://dwtkns.com/srtm). It's easy, and each archive comes with *header* files (with georeference among other things). 37 | You can also check Derek's [work](http://dwtkns.com/portfolio/) or [blog](http://blog.dwtkns.com/) if you are interested in map stuff. 38 | 39 | 40 | ## About Collada files 41 | 42 | ### Why in Collada? 43 | Don't you think it's a bit frustrating to be able to generate a 3D mesh, and just visualize it in a window? The point is to **use** this mesh. Collada is an open format, compatible with famous 3D builders (3DS Max, Maya, Blender...). A terrain mesh could for example be used in a game or in a animation movie... 44 | 45 | ### How to visualize them 46 | If you are using a Mac, this is part of it but it comes with very few options (none actually). 47 | I noticed [MeshLab](http://meshlab.sourceforge.net/) is not so bad but may jam when the file is too big. 48 | Since you have to use PyCollada, you can use the tinny Viewer inside this project. Again, if the file is too big, it might not work. 49 | Apparently SketchUp woks pretty fine as well, but I didn't try it. 50 | 51 | 52 | ## Limitations 53 | 54 | ### Performances 55 | Since it's a pythonic tool, it is not focused on performances, even though Numpy and OpenCV both have low level core... So if you do not want to spend all night waiting for your Collada file, I advise you not to use input files bigger than 1500px by 1500px. (meaning you may have to crop your SRTM tiles...) 56 | 57 | ### Algorithmic 58 | I didn't look for a fancy algorithm to make a 3D mesh from greyscale 2D data, so I made it simple : **1 pixel = 2 triangles**, *(x, y)* in the image are *(x, y)* in the mesh, and grey value are the elevation, after being flattened by the *resolution factor*. If you follow, you will think: 59 | 60 | > « All right but if so, the last row and column must be missing in the 3D mesh! » 61 | 62 | **You would be pretty much right to think so!** 63 | 64 | 65 | 66 | ## Dependencies 67 | 68 | - [OpenCV2](https://github.com/Itseez/opencv) (be sure Python bindings are activated) 69 | - [PyCollada](https://github.com/pycollada/pycollada) 70 | 71 | 72 | ## Going further 73 | *Collada* files, like *OBJ* files, are descriptive and simple text formats, meaning you could create them with more or less any language. To get better performances, DTM2MESH could be coded in C/C++. Feel free to copy/paste the whole part about *vertices indexing* and *triangle index referencing*, because they are a lot of particular cases and you'd rather go play outside than spending an afternoon finding them... 74 | 75 | FYI, those two tricky parts are commented with: 76 | 77 | ```python 78 | # PART 1: 79 | # loops for computing the vertices positions 80 | ``` 81 | 82 | ```python 83 | # PART 2: 84 | # loops for computing triangles. 85 | # A triangle is made of 3 vertices, but we rather use indexes of vertices 86 | # than vertices coordinates themselves (Collada specs are like that...) 87 | ``` 88 | 89 | Have fun. 90 | 91 | -------------------------------------------------------------------------------- /DTM2MESH.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | ''' 4 | Name : DTM2MESH 5 | Author : Jonathan Lurie 6 | Email : lurie.jo@gmail.com 7 | Date : 2015 06 06 8 | Version : 0.1 9 | Licence : MIT 10 | description : Generates a 3D Collada mesh from a Digital Terrain Model (DTM) 11 | Image. 12 | 13 | This is not for production, since performances could be enhanced. 14 | In this purpose, it is better not to use DTM of size higher than 15 | 1000x1000. 16 | 17 | ''' 18 | 19 | import sys 20 | import argparse 21 | import numpy as np 22 | 23 | import cv2 24 | import collada 25 | 26 | # number of meters/pixel for input DTM image 27 | DEFAULT_GROUND_RESOLUTION = 90. 28 | 29 | description =""" 30 | 31 | Generates a 3D Collada mesh from a Digital Terrain Model (DTM) Image. 32 | This is not for production, since performances could be enhanced. 33 | In this purpose, it is better not to use DTM of size higher than 1000x1000. 34 | """ 35 | 36 | def main(): 37 | 38 | # Deals with app arguments 39 | parser = argparse.ArgumentParser(description=description) 40 | parser.add_argument('-input', required=True, help='Input DTM image file') 41 | parser.add_argument('-output', required=True, help='Output Collada file') 42 | parser.add_argument('-resolution', type=int, required=False, default=DEFAULT_GROUND_RESOLUTION, help='Ground resolution of DTM, in m/pixel. (Default : 90, SRTM compliant)') 43 | args = parser.parse_args() 44 | 45 | inputDTM = args.input 46 | outputDAE = args.output 47 | groundResolution = args.resolution 48 | 49 | 50 | # -1 arg means read image as is, instead of forcing 8bit RGB 51 | img = cv2.imread(inputDTM, -1) 52 | 53 | # finding extremas 54 | arrayMin = np.amin(img) 55 | arrayMax = np.amax(img) 56 | 57 | # for keeping aspect ratio 58 | img = img / groundResolution 59 | 60 | 61 | 62 | xSize = img.shape[1] 63 | ySize = img.shape[0] 64 | 65 | zSize = np.amax(img) 66 | 67 | vertices = np.array([]) 68 | triangles = np.array([]) 69 | normales = np.array([]) 70 | 71 | progressStatus = None 72 | 73 | # PART 1: 74 | # loops for computing the vertices positions 75 | for iy in range(0, ySize-1): 76 | 77 | print "vertices : " + str(round(float(iy)/float(ySize)*100., 2)) + "%" 78 | 79 | # temporary array, just to avoid Numping jamming with too big arrays 80 | # TODO : improve this part using a better compromise 81 | tmpVertices = np.array([]) 82 | 83 | for ix in range(0, xSize-1): 84 | 85 | # first row 86 | if(ix == 0 and iy == 0): 87 | a = np.array([ ix, iy, img[ix, iy] ]) 88 | b = np.array([ ix+1, iy, img[ix+1, iy] ]) 89 | c = np.array([ ix+1, iy+1, img[ix+1, iy+1] ]) 90 | d = np.array([ ix, iy+1, img[ix, iy+1] ]) 91 | tmpVertices = np.append(tmpVertices, [a, b, c, d]) 92 | 93 | elif(iy == 0): 94 | b = np.array([ ix+1, iy, img[ix+1, iy] ]) 95 | c = np.array([ ix+1, iy+1, img[ix+1, iy+1] ]) 96 | tmpVertices = np.append(tmpVertices, [b, c]) 97 | 98 | elif(ix == 0): 99 | c = np.array([ ix+1, iy+1, img[ix+1, iy+1] ]) 100 | d = np.array([ ix, iy+1, img[ix, iy+1] ]) 101 | tmpVertices = np.append(tmpVertices, [c, d]) 102 | 103 | else: 104 | c = np.array([ ix+1, iy+1, img[ix+1, iy+1] ]) 105 | tmpVertices = np.append(tmpVertices, [c]) 106 | 107 | vertices = np.append(vertices, tmpVertices) 108 | 109 | # flushing console progress 110 | sys.stdout.write("\033[F") 111 | 112 | vertices.shape = (-1, 3) 113 | 114 | # number of vertices after the first row 115 | firtRowSum = (xSize * 2) - 1 116 | 117 | 118 | # PART 2: 119 | # loops for computing triangles. 120 | # A triangle is made of 3 vertices, but we rather use indexes of vertices 121 | # than vertices coordinates themselves (Collada specs are like that...) 122 | for iy in range(0, ySize-1): 123 | print "triangles : " + str(round(float(iy)/float(ySize)*100., 2)) + "%" 124 | 125 | # temporary array, just to avoid Numping jamming with too big arrays 126 | # TODO : improve this part using a better compromise 127 | tmpTriangles = np.array([]) 128 | 129 | for ix in range(0, xSize-1): 130 | 131 | # (0, 0) 132 | if(ix == 0 and iy == 0): 133 | aIndex = 0 134 | bIndex = 1 135 | cIndex = 2 136 | dIndex = 3 137 | 138 | # (1, 0) 139 | elif(ix == 1 and iy == 0): 140 | aIndex = ix 141 | bIndex = (ix * 2) + 2 142 | cIndex = (ix * 2) + 3 143 | dIndex = ((ix-1) * 2) + 2 144 | 145 | # (0, 1) 146 | elif(ix == 0 and iy == 1): 147 | aIndex = 3 148 | bIndex = 2 149 | cIndex = firtRowSum + 1 150 | dIndex = firtRowSum + 2 151 | 152 | # (1, 1) 153 | elif(ix == 1 and iy == 1): 154 | aIndex = 2 155 | bIndex = 5 156 | cIndex = firtRowSum + 3 157 | dIndex = firtRowSum + 1 158 | 159 | # 1st row (except 1st col) 160 | elif(iy == 0): 161 | aIndex = ix * 2 162 | bIndex = (ix * 2) + 2 163 | cIndex = (ix * 2) + 3 164 | dIndex = ((ix-1) * 2) + 3 165 | 166 | # 2nd row (except 1st and 2nd col) 167 | elif(iy == 1): 168 | aIndex = (ix * 2) + 1 169 | bIndex = (ix * 2) + 3 170 | cIndex = firtRowSum + 2 + ix 171 | dIndex = firtRowSum + 1 + ix 172 | 173 | # 1st col (except 1st and 2nd row) 174 | elif(ix == 0): 175 | aIndex = firtRowSum + (iy - 2) * xSize + 2 176 | bIndex = firtRowSum + (iy - 2) * xSize + 1 177 | cIndex = firtRowSum + (iy - 1) * xSize + 1 178 | dIndex = firtRowSum + (iy - 1) * xSize + 2 179 | 180 | # 2nd col (except 1st and 2nd row) 181 | elif(ix == 1): 182 | aIndex = firtRowSum + (iy - 2) * xSize + 1 183 | bIndex = firtRowSum + (iy - 2) * xSize + 3 184 | cIndex = firtRowSum + (iy - 1) * xSize + 3 185 | dIndex = firtRowSum + (iy - 1) * xSize + 1 186 | 187 | # all other cases 188 | else: 189 | aIndex = firtRowSum + xSize * (iy - 2) + ix + 1 190 | bIndex = firtRowSum + xSize * (iy - 2) + ix + 2 191 | cIndex = firtRowSum + xSize * (iy - 1) + ix + 2 192 | dIndex = firtRowSum + xSize * (iy - 1) + ix + 1 193 | 194 | 195 | # Add triangle T1, vertices a, b, c 196 | t1 = np.array([ aIndex, bIndex, cIndex ]) 197 | tmpTriangles = np.append(tmpTriangles, t1) 198 | 199 | # Add triangle T2, vertices a, c, d 200 | t2 = np.array([ aIndex, cIndex, dIndex ]) 201 | tmpTriangles = np.append(tmpTriangles, t2) 202 | 203 | 204 | ''' 205 | # This part is dedicated to normal vector computation 206 | # but not used yet because I didnt find how to use that with PyCollada 207 | # computation of vectors 208 | vAB = vertices[bIndex] - vertices[aIndex] 209 | vAC = vertices[cIndex] - vertices[aIndex] 210 | vAD = vertices[dIndex] - vertices[aIndex] 211 | 212 | # computation of norm vectors 213 | t1Norm = computeNormVector(vAB, vAC) 214 | t2Norm = computeNormVector(vAC, vAD) 215 | 216 | normales = np.append(normales, t1Norm) 217 | normales = np.append(normales, t2Norm) 218 | ''' 219 | 220 | # 221 | triangles = np.append(triangles, tmpTriangles) 222 | 223 | # flushing console progress 224 | sys.stdout.write("\033[F") 225 | 226 | triangles.shape = (-1, 3) 227 | 228 | # Uncomment the following when eventually using normal vectors 229 | #normales.shape = (-1, 3) 230 | 231 | print("Exporting...") 232 | 233 | # casting npArray to Int was important to garraty compatibility 234 | # with most softwares 235 | export_mesh(vertices, triangles.astype(int), outputDAE) 236 | 237 | 238 | 239 | # vA and vB are 3D vectors, in shape of a numpy array 240 | def computeNormVector(vA, vB): 241 | x = (vA[1] * vB[2]) - (vA[2] * vB[1]) 242 | y = (vA[2] * vB[0]) - (vA[0] * vB[2]) 243 | z = (vA[0] * vB[2]) - (vA[2] * vB[0]) 244 | 245 | norm = ((x**2) + (y**2) + (z**2))**0.5 246 | 247 | return np.array([ x/norm, y/norm, z/norm]) 248 | 249 | # Export the mesh. 250 | # This method is taken from PyMCubes : github.com/pmneila/PyMCubes 251 | # TODO : add normal vectors somewhere 252 | def export_mesh(vertices, triangles, filename, mesh_name="mcubes_mesh"): 253 | """ 254 | Exports a mesh in the COLLADA (.dae) format. 255 | 256 | Needs PyCollada (https://github.com/pycollada/pycollada). 257 | """ 258 | 259 | mesh = collada.Collada() 260 | 261 | vert_src = collada.source.FloatSource("verts-array", vertices, ('X','Y','Z')) 262 | geom = collada.geometry.Geometry(mesh, "geometry0", mesh_name, [vert_src]) 263 | 264 | input_list = collada.source.InputList() 265 | input_list.addInput(0, 'VERTEX', "#verts-array") 266 | 267 | 268 | triset = geom.createTriangleSet(triangles, input_list, "") 269 | geom.primitives.append(triset) 270 | mesh.geometries.append(geom) 271 | 272 | geomnode = collada.scene.GeometryNode(geom, []) 273 | node = collada.scene.Node(mesh_name, children=[geomnode]) 274 | 275 | myscene = collada.scene.Scene("mcubes_scene", [node]) 276 | mesh.scenes.append(myscene) 277 | mesh.scene = myscene 278 | 279 | mesh.write(filename) 280 | 281 | 282 | 283 | 284 | # this is quite the main function... 285 | if __name__ == '__main__': 286 | main() 287 | --------------------------------------------------------------------------------