[{"data":1,"prerenderedAt":1418},["ShallowReactive",2],{"doc:\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Fcreate-dem-from-point-cloud-pyqgis":3},{"id":4,"title":5,"body":6,"description":1407,"extension":1408,"meta":1409,"navigation":339,"path":1414,"seo":1415,"stem":1416,"__hash__":1417},"docs\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Fcreate-dem-from-point-cloud-pyqgis\u002Findex.md","Create a DEM from a Point Cloud in PyQGIS",{"type":7,"value":8,"toc":1394},"minimark",[9,13,17,26,271,276,298,302,305,516,522,526,529,539,631,644,647,705,710,714,802,805,884,907,911,914,1108,1117,1121,1223,1232,1236,1259,1263,1309,1313,1316,1320,1326,1337,1347,1357,1361,1390],[10,11,5],"h1",{"id":12},"create-a-dem-from-a-point-cloud-in-pyqgis",[14,15,16],"p",{},"Turning a cloud into a raster is the step where LiDAR stops being LiDAR and becomes a layer the rest of your workflow can use. It is also the step where most of the mistakes get baked in permanently, because a DEM at the wrong resolution, or filled by the wrong method, looks entirely credible. Nobody opens a hillshade and thinks \"those terraces are interpolation artefacts\"; they think the hillside has terraces.",[14,18,19,20,25],{},"This recipe belongs to ",[21,22,24],"a",{"href":23},"\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002F","Point Cloud & LiDAR Workflows in PyQGIS",". It covers picking a resolution from the data rather than from habit, the two gridding algorithms and when each is right, filling holes without inventing landscape, and mosaicking a tiled survey into one seamless raster.",[14,27,28],{},[29,30,35,39,43,50,59,69,75,101,107,112,117,120,197,200,203,208,212,218,246,249,253,262,267],"svg",{"viewBox":31,"role":32,"ariaLabel":33,"xmlns":34},"0 0 760 330","img","The same ground returns gridded at three resolutions, showing a coarse grid that loses detail, a matched grid that is fully populated, and a fine grid that is mostly empty cells","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[36,37,38],"title",{},"Resolution is decided by point density, not by taste",[40,41,42],"desc",{},"At two metres every cell contains many ground returns and the surface is smooth but coarse. At one metre nearly every cell contains at least one return and the detail is real. At a quarter of a metre most cells contain no return at all, so the raster is mostly holes and whatever fills them is invention.",[44,45],"rect",{"x":46,"y":46,"width":47,"height":48,"fill":49},"0","760","330","#f6f3ea",[51,52,58],"text",{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"380","26","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","4 ground returns per square metre, gridded three ways",[44,60],{"x":61,"y":62,"width":63,"height":64,"rx":65,"fill":66,"stroke":67,"style":68},"20","46","228","196","10","#eef7f4","#0f766e","stroke-width:2.5",[51,70,74],{"x":71,"y":72,"style":73,"fill":67,"textAnchor":57},"134","72","text-anchor:middle;font-size:11.5px;font-weight:bold;font-family:sans-serif","2.0 m cells",[76,77,80,84,87,89,92,95,97,99],"g",{"stroke":67,"style":78,"fill":79},"stroke-width:1.2","#e8efe6",[44,81],{"x":62,"y":82,"width":83,"height":83},"88","44",[44,85],{"x":86,"y":82,"width":83,"height":83},"90",[44,88],{"x":71,"y":82,"width":83,"height":83},[44,90],{"x":91,"y":82,"width":83,"height":83},"178",[44,93],{"x":62,"y":94,"width":83,"height":83},"132",[44,96],{"x":86,"y":94,"width":83,"height":83},[44,98],{"x":71,"y":94,"width":83,"height":83},[44,100],{"x":91,"y":94,"width":83,"height":83},[51,102,106],{"x":71,"y":103,"style":104,"fill":105,"textAnchor":57},"198","text-anchor:middle;font-size:10.5px;font-family:sans-serif","#2f3b35","~16 returns per cell",[51,108,111],{"x":71,"y":109,"style":104,"fill":110,"textAnchor":57},"220","#59645f","smooth, but detail is gone",[44,113],{"x":114,"y":62,"width":63,"height":64,"rx":65,"fill":115,"stroke":116,"style":68},"266","#edf8e9","#15803d",[51,118,119],{"x":53,"y":72,"style":73,"fill":116,"textAnchor":57},"1.0 m cells",[76,121,123,127,130,133,136,138,141,144,147,150,152,154,156,158,160,162,164,166,168,170,172,174,176,178,180,183,185,187,189,191,193,195],{"stroke":116,"style":122,"fill":79},"stroke-width:1",[44,124],{"x":125,"y":82,"width":126,"height":126},"292","22",[44,128],{"x":129,"y":82,"width":126,"height":126},"314",[44,131],{"x":132,"y":82,"width":126,"height":126},"336",[44,134],{"x":135,"y":82,"width":126,"height":126},"358",[44,137],{"x":53,"y":82,"width":126,"height":126},[44,139],{"x":140,"y":82,"width":126,"height":126},"402",[44,142],{"x":143,"y":82,"width":126,"height":126},"424",[44,145],{"x":146,"y":82,"width":126,"height":126},"446",[44,148],{"x":125,"y":149,"width":126,"height":126},"110",[44,151],{"x":129,"y":149,"width":126,"height":126},[44,153],{"x":132,"y":149,"width":126,"height":126},[44,155],{"x":135,"y":149,"width":126,"height":126},[44,157],{"x":53,"y":149,"width":126,"height":126},[44,159],{"x":140,"y":149,"width":126,"height":126},[44,161],{"x":143,"y":149,"width":126,"height":126},[44,163],{"x":146,"y":149,"width":126,"height":126},[44,165],{"x":125,"y":94,"width":126,"height":126},[44,167],{"x":129,"y":94,"width":126,"height":126},[44,169],{"x":132,"y":94,"width":126,"height":126},[44,171],{"x":135,"y":94,"width":126,"height":126},[44,173],{"x":53,"y":94,"width":126,"height":126},[44,175],{"x":140,"y":94,"width":126,"height":126},[44,177],{"x":143,"y":94,"width":126,"height":126},[44,179],{"x":146,"y":94,"width":126,"height":126},[44,181],{"x":125,"y":182,"width":126,"height":126},"154",[44,184],{"x":129,"y":182,"width":126,"height":126},[44,186],{"x":132,"y":182,"width":126,"height":126},[44,188],{"x":135,"y":182,"width":126,"height":126},[44,190],{"x":53,"y":182,"width":126,"height":126},[44,192],{"x":140,"y":182,"width":126,"height":126},[44,194],{"x":143,"y":182,"width":126,"height":126},[44,196],{"x":146,"y":182,"width":126,"height":126},[51,198,199],{"x":53,"y":103,"style":104,"fill":105,"textAnchor":57},"~4 returns per cell",[51,201,202],{"x":53,"y":109,"style":104,"fill":110,"textAnchor":57},"the honest choice here",[44,204],{"x":205,"y":62,"width":63,"height":64,"rx":65,"fill":206,"stroke":207,"style":68},"512","#fdf2e2","#b45309",[51,209,211],{"x":210,"y":72,"style":73,"fill":207,"textAnchor":57},"626","0.25 m cells",[44,213],{"x":214,"y":82,"width":215,"height":82,"fill":216,"stroke":207,"style":217},"538","176","#e7e2d4","stroke-width:1.4",[76,219,220,226,230,234,238,242],{"fill":207},[221,222],"circle",{"cx":223,"cy":224,"r":225},"556","104","2.4",[221,227],{"cx":228,"cy":229,"r":225},"602","120",[221,231],{"cx":232,"cy":233,"r":225},"648","98",[221,235],{"cx":236,"cy":237,"r":225},"694","140",[221,239],{"cx":240,"cy":241,"r":225},"574","158",[221,243],{"cx":244,"cy":245,"r":225},"668","164",[51,247,248],{"x":210,"y":103,"style":104,"fill":105,"textAnchor":57},"most cells empty",[51,250,252],{"x":210,"y":109,"style":104,"fill":251,"textAnchor":57},"#b91c1c","the fill becomes the map",[44,254],{"x":255,"y":256,"width":257,"height":258,"rx":259,"fill":260,"stroke":110,"style":261},"80","264","600","52","8","#fffdf7","stroke-width:2",[51,263,266],{"x":53,"y":264,"style":265,"fill":105,"textAnchor":57},"288","text-anchor:middle;font-size:11px;font-family:sans-serif","rule of thumb: cell size ≈ 1 \u002F √(ground returns per m²), rounded up",[51,268,270],{"x":53,"y":269,"style":104,"fill":110,"textAnchor":57},"308","and ground return density under canopy is a fraction of the survey headline figure",[272,273,275],"h2",{"id":274},"prerequisites","Prerequisites",[277,278,279,287,295],"ul",{},[280,281,282,286],"li",{},[283,284,285],"strong",{},"QGIS 3.34 LTR"," or newer with the PDAL provider.",[280,288,289,290,294],{},"A cloud with ground returns classified, or a decision to build a surface model instead — see ",[21,291,293],{"href":292},"\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Ffilter-and-classify-point-cloud-pyqgis\u002F","filtering and classifying a point cloud",".",[280,296,297],{},"Enough disk for the output: a 1 m float32 raster over 1 km² is about 4 MB before compression.",[272,299,301],{"id":300},"measure-the-density-before-choosing-a-resolution","Measure the density before choosing a resolution",[14,303,304],{},"The survey specification quotes an average pulse density over the whole flight. What you need is the density of the returns you are actually gridding, over the area you are actually gridding, and those two numbers can differ by a factor of ten under woodland.",[306,307,312],"pre",{"className":308,"code":309,"language":310,"meta":311,"style":311},"language-python shiki shiki-themes github-dark","from qgis.core import QgsPointCloudLayer\n\ncloud = QgsPointCloudLayer(\"\u002Fdata\u002Fclean\u002Ftile_0345_ground.copc.laz\", \"ground\", \"copc\")\nextent = cloud.extent()\narea = extent.width() * extent.height()\n\ndensity = cloud.pointCount() \u002F area\nprint(f\"{density:.2f} ground returns per m² over {area \u002F 1e6:.2f} km²\")\nprint(\"suggested cell size:\", round((1 \u002F density) ** 0.5, 2), \"m\")\n","python","",[313,314,315,334,341,371,382,399,404,421,470],"code",{"__ignoreMap":311},[316,317,320,324,328,331],"span",{"class":318,"line":319},"line",1,[316,321,323],{"class":322},"snl16","from",[316,325,327],{"class":326},"s95oV"," qgis.core ",[316,329,330],{"class":322},"import",[316,332,333],{"class":326}," QgsPointCloudLayer\n",[316,335,337],{"class":318,"line":336},2,[316,338,340],{"emptyLinePlaceholder":339},true,"\n",[316,342,344,347,350,353,357,360,363,365,368],{"class":318,"line":343},3,[316,345,346],{"class":326},"cloud ",[316,348,349],{"class":322},"=",[316,351,352],{"class":326}," QgsPointCloudLayer(",[316,354,356],{"class":355},"sU2Wk","\"\u002Fdata\u002Fclean\u002Ftile_0345_ground.copc.laz\"",[316,358,359],{"class":326},", ",[316,361,362],{"class":355},"\"ground\"",[316,364,359],{"class":326},[316,366,367],{"class":355},"\"copc\"",[316,369,370],{"class":326},")\n",[316,372,374,377,379],{"class":318,"line":373},4,[316,375,376],{"class":326},"extent ",[316,378,349],{"class":322},[316,380,381],{"class":326}," cloud.extent()\n",[316,383,385,388,390,393,396],{"class":318,"line":384},5,[316,386,387],{"class":326},"area ",[316,389,349],{"class":322},[316,391,392],{"class":326}," extent.width() ",[316,394,395],{"class":322},"*",[316,397,398],{"class":326}," extent.height()\n",[316,400,402],{"class":318,"line":401},6,[316,403,340],{"emptyLinePlaceholder":339},[316,405,407,410,412,415,418],{"class":318,"line":406},7,[316,408,409],{"class":326},"density ",[316,411,349],{"class":322},[316,413,414],{"class":326}," cloud.pointCount() ",[316,416,417],{"class":322},"\u002F",[316,419,420],{"class":326}," area\n",[316,422,424,428,431,434,437,440,443,446,449,452,454,456,458,461,463,465,468],{"class":318,"line":423},8,[316,425,427],{"class":426},"sDLfK","print",[316,429,430],{"class":326},"(",[316,432,433],{"class":322},"f",[316,435,436],{"class":355},"\"",[316,438,439],{"class":426},"{",[316,441,442],{"class":326},"density",[316,444,445],{"class":322},":.2f",[316,447,448],{"class":426},"}",[316,450,451],{"class":355}," ground returns per m² over ",[316,453,439],{"class":426},[316,455,387],{"class":326},[316,457,417],{"class":322},[316,459,460],{"class":426}," 1e6",[316,462,445],{"class":322},[316,464,448],{"class":426},[316,466,467],{"class":355}," km²\"",[316,469,370],{"class":326},[316,471,473,475,477,480,482,485,488,491,494,497,500,503,505,508,511,514],{"class":318,"line":472},9,[316,474,427],{"class":426},[316,476,430],{"class":326},[316,478,479],{"class":355},"\"suggested cell size:\"",[316,481,359],{"class":326},[316,483,484],{"class":426},"round",[316,486,487],{"class":326},"((",[316,489,490],{"class":426},"1",[316,492,493],{"class":322}," \u002F",[316,495,496],{"class":326}," density) ",[316,498,499],{"class":322},"**",[316,501,502],{"class":426}," 0.5",[316,504,359],{"class":326},[316,506,507],{"class":426},"2",[316,509,510],{"class":326},"), ",[316,512,513],{"class":355},"\"m\"",[316,515,370],{"class":326},[14,517,518,521],{},[283,519,520],{},"Breakdown:"," Dividing by the bounding-box area is a slight underestimate of density wherever the tile is not fully covered, which errs in the safe direction — it suggests a coarser cell than strictly necessary. The square root converts a per-area density into a spacing: at 4 points\u002Fm² the mean spacing is 0.5 m, so a 0.5 m cell holds about one point on average and a 1 m cell holds about four. One point per cell is not enough; aim for a cell size where the typical count is a handful.",[272,523,525],{"id":524},"grid-it-binning-or-triangulation","Grid it: binning or triangulation",[14,527,528],{},"There are two export algorithms and the difference between them is what happens in a cell with no points.",[14,530,531,534,535,538],{},[313,532,533],{},"pdal:exportraster"," bins: each cell takes a statistic of the points that fall inside it, and a cell with no points is nodata. ",[313,536,537],{},"pdal:exportrastertin"," triangulates the points into a TIN and samples the resulting surface, so every cell inside the data's hull gets a value — interpolated across the facet it sits on.",[306,540,542],{"className":308,"code":541,"language":310,"meta":311,"style":311},"import processing\n\ndtm = processing.run(\"pdal:exportraster\", {\n    \"INPUT\": \"\u002Fdata\u002Fclean\u002Ftile_0345_ground.copc.laz\",\n    \"RESOLUTION\": 1.0,\n    \"TILE_SIZE\": 1000,\n    \"OUTPUT\": \"\u002Fdata\u002Foutput\u002Fdtm_0345.tif\",\n})[\"OUTPUT\"]\n",[313,543,544,551,555,571,584,596,608,620],{"__ignoreMap":311},[316,545,546,548],{"class":318,"line":319},[316,547,330],{"class":322},[316,549,550],{"class":326}," processing\n",[316,552,553],{"class":318,"line":336},[316,554,340],{"emptyLinePlaceholder":339},[316,556,557,560,562,565,568],{"class":318,"line":343},[316,558,559],{"class":326},"dtm ",[316,561,349],{"class":322},[316,563,564],{"class":326}," processing.run(",[316,566,567],{"class":355},"\"pdal:exportraster\"",[316,569,570],{"class":326},", {\n",[316,572,573,576,579,581],{"class":318,"line":373},[316,574,575],{"class":355},"    \"INPUT\"",[316,577,578],{"class":326},": ",[316,580,356],{"class":355},[316,582,583],{"class":326},",\n",[316,585,586,589,591,594],{"class":318,"line":384},[316,587,588],{"class":355},"    \"RESOLUTION\"",[316,590,578],{"class":326},[316,592,593],{"class":426},"1.0",[316,595,583],{"class":326},[316,597,598,601,603,606],{"class":318,"line":401},[316,599,600],{"class":355},"    \"TILE_SIZE\"",[316,602,578],{"class":326},[316,604,605],{"class":426},"1000",[316,607,583],{"class":326},[316,609,610,613,615,618],{"class":318,"line":406},[316,611,612],{"class":355},"    \"OUTPUT\"",[316,614,578],{"class":326},[316,616,617],{"class":355},"\"\u002Fdata\u002Foutput\u002Fdtm_0345.tif\"",[316,619,583],{"class":326},[316,621,622,625,628],{"class":318,"line":423},[316,623,624],{"class":326},"})[",[316,626,627],{"class":355},"\"OUTPUT\"",[316,629,630],{"class":326},"]\n",[14,632,633,635,636,639,640,643],{},[283,634,520],{}," With the cloud already filtered to ground, no ",[313,637,638],{},"FILTER_EXPRESSION"," is needed — one less thing to get wrong. ",[313,641,642],{},"TILE_SIZE"," is the algorithm's internal chunk in map units, not the output tiling; raising it uses more memory and slightly fewer edge computations. The output is a single-band float32 GeoTIFF in the cloud's CRS, which means it inherits any CRS problem the cloud had, so fix that before this step rather than after.",[14,645,646],{},"Use the TIN variant when the binned output is holey:",[306,648,650],{"className":308,"code":649,"language":310,"meta":311,"style":311},"dtm_tin = processing.run(\"pdal:exportrastertin\", {\n    \"INPUT\": \"\u002Fdata\u002Fclean\u002Ftile_0345_ground.copc.laz\",\n    \"RESOLUTION\": 1.0,\n    \"OUTPUT\": \"\u002Fdata\u002Foutput\u002Fdtm_0345_tin.tif\",\n})[\"OUTPUT\"]\n",[313,651,652,666,676,686,697],{"__ignoreMap":311},[316,653,654,657,659,661,664],{"class":318,"line":319},[316,655,656],{"class":326},"dtm_tin ",[316,658,349],{"class":322},[316,660,564],{"class":326},[316,662,663],{"class":355},"\"pdal:exportrastertin\"",[316,665,570],{"class":326},[316,667,668,670,672,674],{"class":318,"line":336},[316,669,575],{"class":355},[316,671,578],{"class":326},[316,673,356],{"class":355},[316,675,583],{"class":326},[316,677,678,680,682,684],{"class":318,"line":343},[316,679,588],{"class":355},[316,681,578],{"class":326},[316,683,593],{"class":426},[316,685,583],{"class":326},[316,687,688,690,692,695],{"class":318,"line":373},[316,689,612],{"class":355},[316,691,578],{"class":326},[316,693,694],{"class":355},"\"\u002Fdata\u002Foutput\u002Fdtm_0345_tin.tif\"",[316,696,583],{"class":326},[316,698,699,701,703],{"class":318,"line":384},[316,700,624],{"class":326},[316,702,627],{"class":355},[316,704,630],{"class":326},[14,706,707,709],{},[283,708,520],{}," Triangulation is considerably slower and it is honest about what it is doing — it interpolates linearly between measured points, which is the most defensible thing to do in a gap. Where it goes wrong is across genuinely unmeasured areas such as a lake or a building footprint removed by the ground filter: it will span them with a flat triangle, producing a smooth ramp where there is no data at all. Mask those areas afterwards rather than trusting the fill.",[272,711,713],{"id":712},"deciding-what-to-do-with-the-holes","Deciding what to do with the holes",[14,715,716],{},[29,717,720,723,726,728,731,736,740,744,749,753,758,762,764,767,770,773,776,778,781,783,786,789,792,795,799],{"viewBox":718,"role":32,"ariaLabel":719,"xmlns":34},"0 0 760 314","Three ways of handling cells with no ground returns: leave them as nodata, interpolate across them with a TIN, or fill only small gaps and mask the large ones",[36,721,722],{},"A gap is data too",[40,724,725],{},"Leaving a gap as nodata is truthful and awkward downstream. Interpolating across it with a triangulation produces a continuous raster and quietly invents a surface under buildings and water. Filling only gaps below a size threshold and masking the rest keeps the raster usable while refusing to invent large areas.",[44,727],{"x":46,"y":46,"width":47,"height":129,"fill":49},[51,729,730],{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"Three answers, one of which is usually right",[44,732],{"x":61,"y":62,"width":63,"height":733,"rx":65,"fill":734,"stroke":735,"style":68},"180","#eff3ff","#2563eb",[51,737,739],{"x":71,"y":72,"style":738,"fill":735,"textAnchor":57},"text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif","leave it as nodata",[44,741],{"x":62,"y":82,"width":215,"height":72,"fill":742,"stroke":735,"style":743},"#dbeafe","stroke-width:1.6",[44,745],{"x":746,"y":747,"width":748,"height":748,"fill":216,"stroke":110,"style":217},"96","106","34",[44,750],{"x":241,"y":751,"width":752,"height":752,"fill":216,"stroke":110,"style":217},"118","24",[51,754,757],{"x":71,"y":755,"style":756,"fill":105,"textAnchor":57},"184","text-anchor:middle;font-size:10px;font-family:sans-serif","truthful; breaks slope",[51,759,761],{"x":71,"y":760,"style":756,"fill":105,"textAnchor":57},"204","and hillshade at the edges",[44,763],{"x":114,"y":62,"width":63,"height":733,"rx":65,"fill":206,"stroke":207,"style":68},[51,765,766],{"x":53,"y":72,"style":738,"fill":207,"textAnchor":57},"interpolate everything",[44,768],{"x":125,"y":82,"width":215,"height":72,"fill":769,"stroke":207,"style":743},"#f3e9d8",[51,771,772],{"x":53,"y":755,"style":756,"fill":105,"textAnchor":57},"continuous and convenient",[51,774,775],{"x":53,"y":760,"style":756,"fill":251,"textAnchor":57},"invents ground under lakes",[44,777],{"x":205,"y":62,"width":63,"height":733,"rx":65,"fill":115,"stroke":116,"style":68},[51,779,780],{"x":210,"y":72,"style":738,"fill":116,"textAnchor":57},"fill small, mask large",[44,782],{"x":214,"y":82,"width":215,"height":72,"fill":79,"stroke":116,"style":743},[44,784],{"x":785,"y":747,"width":748,"height":748,"fill":216,"stroke":110,"style":217},"588",[51,787,788],{"x":210,"y":755,"style":756,"fill":105,"textAnchor":57},"a threshold you can defend",[51,790,791],{"x":210,"y":760,"style":756,"fill":105,"textAnchor":57},"and a mask you can publish",[44,793],{"x":255,"y":794,"width":257,"height":258,"rx":259,"fill":260,"stroke":110,"style":261},"248",[51,796,798],{"x":53,"y":797,"style":265,"fill":105,"textAnchor":57},"272","whichever you choose, record it — a DEM with no gap policy stated is not reproducible",[51,800,801],{"x":53,"y":125,"style":104,"fill":110,"textAnchor":57},"the layer metadata is the natural place for it",[14,803,804],{},"The middle route — fill gaps up to a size you are willing to defend, and leave the rest — is a single GDAL algorithm.",[306,806,808],{"className":308,"code":807,"language":310,"meta":311,"style":311},"filled = processing.run(\"gdal:fillnodata\", {\n    \"INPUT\": dtm,\n    \"BAND\": 1,\n    \"DISTANCE\": 6,\n    \"ITERATIONS\": 0,\n    \"OUTPUT\": \"\u002Fdata\u002Foutput\u002Fdtm_0345_filled.tif\",\n})[\"OUTPUT\"]\n",[313,809,810,824,831,842,854,865,876],{"__ignoreMap":311},[316,811,812,815,817,819,822],{"class":318,"line":319},[316,813,814],{"class":326},"filled ",[316,816,349],{"class":322},[316,818,564],{"class":326},[316,820,821],{"class":355},"\"gdal:fillnodata\"",[316,823,570],{"class":326},[316,825,826,828],{"class":318,"line":336},[316,827,575],{"class":355},[316,829,830],{"class":326},": dtm,\n",[316,832,833,836,838,840],{"class":318,"line":343},[316,834,835],{"class":355},"    \"BAND\"",[316,837,578],{"class":326},[316,839,490],{"class":426},[316,841,583],{"class":326},[316,843,844,847,849,852],{"class":318,"line":373},[316,845,846],{"class":355},"    \"DISTANCE\"",[316,848,578],{"class":326},[316,850,851],{"class":426},"6",[316,853,583],{"class":326},[316,855,856,859,861,863],{"class":318,"line":384},[316,857,858],{"class":355},"    \"ITERATIONS\"",[316,860,578],{"class":326},[316,862,46],{"class":426},[316,864,583],{"class":326},[316,866,867,869,871,874],{"class":318,"line":401},[316,868,612],{"class":355},[316,870,578],{"class":326},[316,872,873],{"class":355},"\"\u002Fdata\u002Foutput\u002Fdtm_0345_filled.tif\"",[316,875,583],{"class":326},[316,877,878,880,882],{"class":318,"line":406},[316,879,624],{"class":326},[316,881,627],{"class":355},[316,883,630],{"class":326},[14,885,886,888,889,892,893,897,898,901,902,906],{},[283,887,520],{}," ",[313,890,891],{},"DISTANCE"," is in ",[894,895,896],"em",{},"pixels",", so on a 1 m raster this fills anything within six metres of a measured cell and leaves larger voids alone — which is roughly the right behaviour for gaps under isolated trees while refusing to fill a lake. ",[313,899,900],{},"ITERATIONS"," controls a smoothing pass over the filled values; zero keeps the interpolation crisp. Record the distance you used in the layer's metadata, as covered in ",[21,903,905],{"href":904},"\u002Fpyqgis-fundamentals-environment-setup\u002Fworking-with-qgis-projects\u002Fread-and-write-layer-metadata-pyqgis\u002F","reading and writing layer metadata",", because six months later nobody will remember.",[272,908,910],{"id":909},"mosaicking-a-tiled-survey","Mosaicking a tiled survey",[14,912,913],{},"Grid each tile, then merge. Gridding the whole survey in one call works and is simple, but it gives you no restart point and no parallelism.",[306,915,917],{"className":308,"code":916,"language":310,"meta":311,"style":311},"import glob\nimport os\n\nrasters = []\nfor tile in sorted(glob.glob(\"\u002Fdata\u002Fclean\u002F*_ground.copc.laz\")):\n    out = tile.replace(\"\u002Fclean\u002F\", \"\u002Foutput\u002F\").replace(\"_ground.copc.laz\", \".tif\")\n    if not os.path.exists(out):\n        processing.run(\"pdal:exportraster\", {\n            \"INPUT\": tile, \"RESOLUTION\": 1.0, \"OUTPUT\": out,\n        })\n    rasters.append(out)\n\nprocessing.run(\"gdal:buildvirtualraster\", {\n    \"INPUT\": rasters,\n    \"RESOLUTION\": 0,\n    \"OUTPUT\": \"\u002Fdata\u002Foutput\u002Fdtm_survey.vrt\",\n})\n",[313,918,919,926,933,937,947,970,1001,1012,1021,1043,1049,1055,1060,1071,1079,1090,1102],{"__ignoreMap":311},[316,920,921,923],{"class":318,"line":319},[316,922,330],{"class":322},[316,924,925],{"class":326}," glob\n",[316,927,928,930],{"class":318,"line":336},[316,929,330],{"class":322},[316,931,932],{"class":326}," os\n",[316,934,935],{"class":318,"line":343},[316,936,340],{"emptyLinePlaceholder":339},[316,938,939,942,944],{"class":318,"line":373},[316,940,941],{"class":326},"rasters ",[316,943,349],{"class":322},[316,945,946],{"class":326}," []\n",[316,948,949,952,955,958,961,964,967],{"class":318,"line":384},[316,950,951],{"class":322},"for",[316,953,954],{"class":326}," tile ",[316,956,957],{"class":322},"in",[316,959,960],{"class":426}," sorted",[316,962,963],{"class":326},"(glob.glob(",[316,965,966],{"class":355},"\"\u002Fdata\u002Fclean\u002F*_ground.copc.laz\"",[316,968,969],{"class":326},")):\n",[316,971,972,975,977,980,983,985,988,991,994,996,999],{"class":318,"line":401},[316,973,974],{"class":326},"    out ",[316,976,349],{"class":322},[316,978,979],{"class":326}," tile.replace(",[316,981,982],{"class":355},"\"\u002Fclean\u002F\"",[316,984,359],{"class":326},[316,986,987],{"class":355},"\"\u002Foutput\u002F\"",[316,989,990],{"class":326},").replace(",[316,992,993],{"class":355},"\"_ground.copc.laz\"",[316,995,359],{"class":326},[316,997,998],{"class":355},"\".tif\"",[316,1000,370],{"class":326},[316,1002,1003,1006,1009],{"class":318,"line":406},[316,1004,1005],{"class":322},"    if",[316,1007,1008],{"class":322}," not",[316,1010,1011],{"class":326}," os.path.exists(out):\n",[316,1013,1014,1017,1019],{"class":318,"line":423},[316,1015,1016],{"class":326},"        processing.run(",[316,1018,567],{"class":355},[316,1020,570],{"class":326},[316,1022,1023,1026,1029,1032,1034,1036,1038,1040],{"class":318,"line":472},[316,1024,1025],{"class":355},"            \"INPUT\"",[316,1027,1028],{"class":326},": tile, ",[316,1030,1031],{"class":355},"\"RESOLUTION\"",[316,1033,578],{"class":326},[316,1035,593],{"class":426},[316,1037,359],{"class":326},[316,1039,627],{"class":355},[316,1041,1042],{"class":326},": out,\n",[316,1044,1046],{"class":318,"line":1045},10,[316,1047,1048],{"class":326},"        })\n",[316,1050,1052],{"class":318,"line":1051},11,[316,1053,1054],{"class":326},"    rasters.append(out)\n",[316,1056,1058],{"class":318,"line":1057},12,[316,1059,340],{"emptyLinePlaceholder":339},[316,1061,1063,1066,1069],{"class":318,"line":1062},13,[316,1064,1065],{"class":326},"processing.run(",[316,1067,1068],{"class":355},"\"gdal:buildvirtualraster\"",[316,1070,570],{"class":326},[316,1072,1074,1076],{"class":318,"line":1073},14,[316,1075,575],{"class":355},[316,1077,1078],{"class":326},": rasters,\n",[316,1080,1082,1084,1086,1088],{"class":318,"line":1081},15,[316,1083,588],{"class":355},[316,1085,578],{"class":326},[316,1087,46],{"class":426},[316,1089,583],{"class":326},[316,1091,1093,1095,1097,1100],{"class":318,"line":1092},16,[316,1094,612],{"class":355},[316,1096,578],{"class":326},[316,1098,1099],{"class":355},"\"\u002Fdata\u002Foutput\u002Fdtm_survey.vrt\"",[316,1101,583],{"class":326},[316,1103,1105],{"class":318,"line":1104},17,[316,1106,1107],{"class":326},"})\n",[14,1109,1110,1112,1113,1116],{},[283,1111,520],{}," A VRT is the right mosaic here: it is a small XML file referencing the tiles, so building it is instant, it costs no extra disk, and every downstream algorithm treats it as one seamless raster. ",[313,1114,1115],{},"RESOLUTION: 0"," means \"average of the inputs\", which is correct only when every input has the same cell size — pass 1 (highest) if they do not, and expect resampling. If the tiles were gridded with matching cell sizes and aligned origins the mosaic has no seams; if the origins do not align, a half-cell offset produces a visible grid of hairlines under hillshade.",[272,1118,1120],{"id":1119},"check-the-result-before-you-use-it","Check the result before you use it",[306,1122,1124],{"className":308,"code":1123,"language":310,"meta":311,"style":311},"from qgis.core import QgsRasterLayer\n\ndem = QgsRasterLayer(\"\u002Fdata\u002Foutput\u002Fdtm_survey.vrt\", \"dtm\")\nstats = dem.dataProvider().bandStatistics(1)\nprint(f\"min {stats.minimumValue:.1f} max {stats.maximumValue:.1f} mean {stats.mean:.1f}\")\n",[313,1125,1126,1137,1141,1160,1174],{"__ignoreMap":311},[316,1127,1128,1130,1132,1134],{"class":318,"line":319},[316,1129,323],{"class":322},[316,1131,327],{"class":326},[316,1133,330],{"class":322},[316,1135,1136],{"class":326}," QgsRasterLayer\n",[316,1138,1139],{"class":318,"line":336},[316,1140,340],{"emptyLinePlaceholder":339},[316,1142,1143,1146,1148,1151,1153,1155,1158],{"class":318,"line":343},[316,1144,1145],{"class":326},"dem ",[316,1147,349],{"class":322},[316,1149,1150],{"class":326}," QgsRasterLayer(",[316,1152,1099],{"class":355},[316,1154,359],{"class":326},[316,1156,1157],{"class":355},"\"dtm\"",[316,1159,370],{"class":326},[316,1161,1162,1165,1167,1170,1172],{"class":318,"line":373},[316,1163,1164],{"class":326},"stats ",[316,1166,349],{"class":322},[316,1168,1169],{"class":326}," dem.dataProvider().bandStatistics(",[316,1171,490],{"class":426},[316,1173,370],{"class":326},[316,1175,1176,1178,1180,1182,1185,1187,1190,1193,1195,1198,1200,1203,1205,1207,1210,1212,1215,1217,1219,1221],{"class":318,"line":384},[316,1177,427],{"class":426},[316,1179,430],{"class":326},[316,1181,433],{"class":322},[316,1183,1184],{"class":355},"\"min ",[316,1186,439],{"class":426},[316,1188,1189],{"class":326},"stats.minimumValue",[316,1191,1192],{"class":322},":.1f",[316,1194,448],{"class":426},[316,1196,1197],{"class":355}," max ",[316,1199,439],{"class":426},[316,1201,1202],{"class":326},"stats.maximumValue",[316,1204,1192],{"class":322},[316,1206,448],{"class":426},[316,1208,1209],{"class":355}," mean ",[316,1211,439],{"class":426},[316,1213,1214],{"class":326},"stats.mean",[316,1216,1192],{"class":322},[316,1218,448],{"class":426},[316,1220,436],{"class":355},[316,1222,370],{"class":326},[14,1224,1225,1227,1228,294],{},[283,1226,520],{}," Three numbers catch most disasters. A minimum near zero over hilly ground means nodata is being read as a value rather than as nodata. A maximum in the thousands over lowland means noise points survived the filter. A mean far from the survey's stated ground elevation means the vertical datum is not what you assumed. All three are five seconds of checking against hours of rework, and the same statistics call underpins ",[21,1229,1231],{"href":1230},"\u002Fspatial-data-processing-automation\u002Fraster-analysis-workflows\u002Fcalculate-raster-statistics-pyqgis\u002F","calculating raster statistics",[272,1233,1235],{"id":1234},"qgis-version-compatibility","QGIS version compatibility",[14,1237,1238,1240,1241,1243,1244,1240,1247,1250,1251,1254,1255,1258],{},[313,1239,533],{}," and ",[313,1242,537],{}," both arrived in 3.32 with the parameter names used here and are unchanged through 3.44. ",[313,1245,1246],{},"gdal:fillnodata",[313,1248,1249],{},"gdal:buildvirtualraster"," are long-standing GDAL wrappers, though ",[313,1252,1253],{},"fillnodata"," gained a ",[313,1256,1257],{},"MASK_LAYER"," parameter in 3.28 that is worth using where you already have a water mask.",[272,1260,1262],{"id":1261},"troubleshooting","Troubleshooting",[277,1264,1265,1271,1277,1283,1289,1299],{},[280,1266,1267,1270],{},[283,1268,1269],{},"The raster is all nodata."," No points matched — either the cloud is empty over this extent or a filter expression matched nothing.",[280,1272,1273,1276],{},[283,1274,1275],{},"Terraced, stepped hillsides in the hillshade."," The DEM was written as an integer type, or the source Z was rounded. Keep float32 throughout.",[280,1278,1279,1282],{},[283,1280,1281],{},"Hairline seams across the mosaic."," Tile origins are not aligned to a common grid. Snap the extents to a multiple of the cell size when gridding.",[280,1284,1285,1288],{},[283,1286,1287],{},"A flat plateau where a lake is."," TIN interpolation spanned an unmeasured area. Mask water before gridding, or grid with binning and fill only small gaps.",[280,1290,1291,1294,1295,1298],{},[283,1292,1293],{},"The output is enormous."," No compression. Add ",[313,1296,1297],{},"-co COMPRESS=DEFLATE -co PREDICTOR=3"," through the algorithm's creation options; float DEMs compress well with predictor 3.",[280,1300,1301,1304,1305,294],{},[283,1302,1303],{},"Slope looks wrong by orders of magnitude."," The DEM is in a geographic CRS, so horizontal units are degrees while heights are metres — the same trap described in ",[21,1306,1308],{"href":1307},"\u002Fspatial-data-processing-automation\u002Fterrain-and-interpolation-analysis\u002F","terrain and interpolation analysis",[272,1310,1312],{"id":1311},"conclusion","Conclusion",[14,1314,1315],{},"Compute the density, derive the cell size from it, bin when the returns are dense and triangulate when they are not, fill only the gaps you can defend and record the threshold, then mosaic through a VRT and check three statistics before anything else touches the raster. None of those steps is difficult; skipping them is what produces a DEM that is wrong in a way nobody notices until it has been published.",[272,1317,1319],{"id":1318},"frequently-asked-questions","Frequently Asked Questions",[14,1321,1322,1325],{},[283,1323,1324],{},"Should I build a DEM or a DSM?","\nA terrain model, built from ground returns, for anything about water, earthworks or accessibility. A surface model, built from the highest return per cell, for anything about what a signal, a view or the sun can reach. They are different products and neither substitutes for the other.",[14,1327,1328,1331,1332,1336],{},[283,1329,1330],{},"Can I get a canopy height model directly?","\nNot in one call. Grid the surface and the terrain separately at the same resolution and origin, then subtract with ",[21,1333,1335],{"href":1334},"\u002Fspatial-data-processing-automation\u002Fraster-analysis-workflows\u002Fraster-calculator-pyqgis\u002F","the raster calculator",". Matching the grids exactly is what makes the subtraction meaningful.",[14,1338,1339,1342,1343,1346],{},[283,1340,1341],{},"Which statistic does pdal:exportraster use per cell?","\nIt writes several bands when asked, including mean, minimum, maximum, count and inverse-distance-weighted values; the default output is the one most builds label as mean. Check ",[313,1344,1345],{},"parameterDefinitions()"," on your build rather than assuming, since the available outputs grew across releases.",[14,1348,1349,1352,1353,1356],{},[283,1350,1351],{},"Is a VRT mosaic slower than a real merged raster?","\nMarginally, on random access across many tiles. For sequential work it is indistinguishable, and the flexibility of not duplicating hundreds of gigabytes usually wins. Materialise it with ",[313,1354,1355],{},"gdal:translate"," only when profiling says it matters.",[272,1358,1360],{"id":1359},"related","Related",[277,1362,1363,1368,1373,1379,1385],{},[280,1364,1365,1367],{},[21,1366,24],{"href":23}," — the guide this recipe belongs to",[280,1369,1370],{},[21,1371,1372],{"href":292},"Filter and Classify a Point Cloud in PyQGIS",[280,1374,1375],{},[21,1376,1378],{"href":1377},"\u002Fspatial-data-processing-automation\u002Fterrain-and-interpolation-analysis\u002Fgenerate-slope-aspect-hillshade-pyqgis\u002F","Generate Slope, Aspect and Hillshade in PyQGIS",[280,1380,1381],{},[21,1382,1384],{"href":1383},"\u002Fspatial-data-processing-automation\u002Fterrain-and-interpolation-analysis\u002Fcreate-contours-from-dem-pyqgis\u002F","Create Contours from a DEM in PyQGIS",[280,1386,1387],{},[21,1388,1389],{"href":1230},"Calculate Raster Statistics in PyQGIS",[1391,1392,1393],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":311,"searchDepth":336,"depth":336,"links":1395},[1396,1397,1398,1399,1400,1401,1402,1403,1404,1405,1406],{"id":274,"depth":336,"text":275},{"id":300,"depth":336,"text":301},{"id":524,"depth":336,"text":525},{"id":712,"depth":336,"text":713},{"id":909,"depth":336,"text":910},{"id":1119,"depth":336,"text":1120},{"id":1234,"depth":336,"text":1235},{"id":1261,"depth":336,"text":1262},{"id":1311,"depth":336,"text":1312},{"id":1318,"depth":336,"text":1319},{"id":1359,"depth":336,"text":1360},"Grid LiDAR into a terrain or surface model from Python — choose a defensible resolution, fill the gaps honestly, mosaic tiles without seams, and check the result before you use it.","md",{"slug":1410,"type":1411,"breadcrumb":1412,"datePublished":1413,"dateModified":1413},"create-dem-from-point-cloud-pyqgis","article","Create a DEM from a Point Cloud","2026-09-04","\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Fcreate-dem-from-point-cloud-pyqgis",{"title":5,"description":1407},"spatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Fcreate-dem-from-point-cloud-pyqgis\u002Findex","f1h25S5pjDOyUVRJAfDjizw6oFHGYaFEBPa2OacFVVI",1788563856095]