[{"data":1,"prerenderedAt":1752},["ShallowReactive",2],{"doc:\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows":3},{"id":4,"title":5,"body":6,"description":1741,"extension":1742,"meta":1743,"navigation":291,"path":1748,"seo":1749,"stem":1750,"__hash__":1751},"docs\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Findex.md","Point Cloud & LiDAR Workflows in PyQGIS",{"type":7,"value":8,"toc":1727},"minimark",[9,13,22,31,212,217,232,242,252,259,346,379,383,386,490,534,541,605,610,614,617,810,813,849,875,879,898,1004,1019,1034,1044,1092,1096,1099,1242,1249,1267,1271,1308,1447,1460,1472,1476,1479,1495,1499,1506,1517,1532,1536,1539,1548,1554,1558,1604,1608,1624,1640,1650,1656,1668,1672,1723],[10,11,5],"h1",{"id":12},"point-cloud-lidar-workflows-in-pyqgis",[14,15,16,17,21],"p",{},"A LiDAR survey arrives as a folder of ",[18,19,20],"code",{},".laz"," files and a specification that says \"ground and buildings, 1 m DEM, tiled\". Nothing in that sentence is hard. What makes point clouds different from every other dataset in QGIS is scale: a single tile holds more points than the whole rest of your project holds features, and the API is built around that fact rather than around iterating features one at a time.",[14,23,24,25,30],{},"This guide sits inside ",[26,27,29],"a",{"href":28},"\u002Fspatial-data-processing-automation\u002F","Spatial Data Processing & Automation"," and covers the whole path — getting a cloud into a layer, reading what is actually in it, filtering and clipping it, and converting it into the raster or vector products that the rest of a workflow can consume. The recurring theme is that you almost never touch individual points from Python. You describe what you want and let an indexed reader or a PDAL algorithm do the walking.",[14,32,33],{},[34,35,40,44,48,55,72,81,90,96,101,108,113,117,120,124,130,134,137,141,147,151,154,158,161,164,172,178,182,185,188,191,194,198,201,207],"svg",{"viewBox":36,"role":37,"ariaLabel":38,"xmlns":39},"0 0 760 350","img","A point cloud pipeline: raw LAZ files are indexed into COPC, opened as a point cloud layer, and then feed rendering, PDAL processing algorithms and exported raster or vector products","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[41,42,43],"title",{},"How a LiDAR file becomes something you can use",[45,46,47],"desc",{},"Raw LAS or LAZ files are indexed into a COPC or EPT structure. QGIS opens the indexed cloud as a point cloud layer, which then feeds three routes: a renderer for display, the PDAL processing algorithms for filtering and conversion, and exported raster or vector products for downstream analysis.",[49,50],"rect",{"x":51,"y":51,"width":52,"height":53,"fill":54},"0","760","350","#f6f3ea",[56,57,58],"defs",{},[59,60,67],"marker",{"id":61,"viewBox":62,"refX":63,"refY":64,"markerWidth":65,"markerHeight":65,"orient":66},"pclOvArrow","0 0 10 10","8","5","7","auto-start-reverse",[68,69],"path",{"d":70,"fill":71},"M0 0 L10 5 L0 10 z","#2f3b35",[73,74,80],"text",{"x":75,"y":76,"style":77,"fill":78,"textAnchor":79},"380","28","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","Indexing happens once; everything downstream depends on it",[49,82],{"x":83,"y":84,"width":85,"height":86,"rx":63,"fill":87,"stroke":88,"style":89},"20","56","150","70","#fdf2e2","#b45309","stroke-width:2.5",[73,91,95],{"x":92,"y":93,"style":94,"fill":88,"textAnchor":79},"95","84","text-anchor:middle;font-size:11.5px;font-weight:bold;font-family:sans-serif","raw LAS \u002F LAZ",[73,97,100],{"x":92,"y":98,"style":99,"fill":71,"textAnchor":79},"106","text-anchor:middle;font-size:10.5px;font-family:sans-serif","one file per tile",[102,103],"line",{"x1":104,"y1":105,"x2":106,"y2":105,"stroke":71,"style":107},"170","91","192","stroke-width:2;marker-end:url(#pclOvArrow)",[49,109],{"x":110,"y":84,"width":85,"height":86,"rx":63,"fill":111,"stroke":112,"style":89},"198","#eff3ff","#2563eb",[73,114,116],{"x":115,"y":93,"style":94,"fill":112,"textAnchor":79},"273","spatial index",[73,118,119],{"x":115,"y":98,"style":99,"fill":71,"textAnchor":79},"COPC or EPT octree",[102,121],{"x1":122,"y1":105,"x2":123,"y2":105,"stroke":71,"style":107},"348","370",[49,125],{"x":126,"y":84,"width":127,"height":86,"rx":63,"fill":128,"stroke":129,"style":89},"376","164","#eef7f4","#0f766e",[73,131,133],{"x":132,"y":93,"style":94,"fill":129,"textAnchor":79},"458","point cloud layer",[73,135,136],{"x":132,"y":98,"style":99,"fill":71,"textAnchor":79},"extent · attributes · stats",[102,138],{"x1":139,"y1":105,"x2":140,"y2":105,"stroke":71,"style":107},"540","562",[49,142],{"x":143,"y":84,"width":144,"height":86,"rx":63,"fill":145,"stroke":146,"style":89},"568","172","#edf8e9","#15803d",[73,148,150],{"x":149,"y":93,"style":94,"fill":146,"textAnchor":79},"654","renderer",[73,152,153],{"x":149,"y":98,"style":99,"fill":71,"textAnchor":79},"2D canvas and 3D view",[68,155],{"d":156,"fill":157,"stroke":71,"style":107},"M458 126 L458 162 L135 162 L135 194","none",[68,159],{"d":160,"fill":157,"stroke":71,"style":107},"M458 126 L458 162 L380 162 L380 194",[68,162],{"d":163,"fill":157,"stroke":71,"style":107},"M458 126 L458 162 L625 162 L625 194",[49,165],{"x":166,"y":167,"width":168,"height":169,"rx":63,"fill":170,"stroke":129,"style":171},"30","200","210","72","#fffdf7","stroke-width:2",[73,173,177],{"x":174,"y":175,"style":176,"fill":129,"textAnchor":79},"135","226","text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif","filter and clip",[73,179,181],{"x":174,"y":180,"style":99,"fill":71,"textAnchor":79},"248","subset string · pdal:clip",[49,183],{"x":184,"y":167,"width":168,"height":169,"rx":63,"fill":170,"stroke":112,"style":171},"275",[73,186,187],{"x":75,"y":175,"style":176,"fill":112,"textAnchor":79},"export raster",[73,189,190],{"x":75,"y":180,"style":99,"fill":71,"textAnchor":79},"DEM · DSM · density",[49,192],{"x":193,"y":167,"width":168,"height":169,"rx":63,"fill":170,"stroke":88,"style":171},"520",[73,195,197],{"x":196,"y":175,"style":176,"fill":88,"textAnchor":79},"625","export vector",[73,199,200],{"x":196,"y":180,"style":99,"fill":71,"textAnchor":79},"boundary · thinned points",[49,202],{"x":203,"y":204,"width":193,"height":205,"rx":63,"fill":170,"stroke":206,"style":171},"120","292","46","#b91c1c",[73,208,211],{"x":75,"y":209,"style":210,"fill":206,"textAnchor":79},"320","text-anchor:middle;font-size:11px;font-family:sans-serif","an unindexed LAZ opens, then re-indexes itself on every single run",[213,214,216],"h2",{"id":215},"what-a-point-cloud-layer-actually-is","What a point cloud layer actually is",[14,218,219,222,223,226,227,231],{},[18,220,221],{},"QgsPointCloudLayer"," is not a vector layer with a lot of features. It has no ",[18,224,225],{},"getFeatures()",", no attribute table you can edit, and no editing buffer. What it has is an ",[228,229,230],"em",{},"index",": a hierarchical octree that stores points in nodes, coarse near the root and dense at the leaves, so that a renderer or an algorithm can ask for \"enough points to fill this screen at this zoom\" and read a few megabytes instead of a few gigabytes.",[14,233,234,235,238,239,241],{},"That is why the format matters more here than anywhere else in QGIS. A plain ",[18,236,237],{},".las"," or ",[18,240,20],{}," file has no index. QGIS will still open it, but it has to build an index first, and it does that in a background task that writes into a cache directory. Do that inside a batch script and you pay the indexing cost on every run, on every machine, for every file.",[14,243,244,245,247,248,251],{},"The practical consequence is that the first decision in any LiDAR project is a storage decision, not a code decision. Convert the delivery once, keep the converted copies, and point every script at those. A survey delivered as three hundred ",[18,246,20],{}," tiles becomes three hundred ",[18,249,250],{},".copc.laz"," tiles occupying roughly the same disk space, after which every subsequent read is a seek into an octree rather than a linear decompression of the whole file. The conversion is one algorithm call per file and it parallelises perfectly, so it is worth doing even when the immediate task only needs two tiles.",[14,253,254,255,258],{},"It also changes what \"open the layer\" costs. Adding an indexed cloud to a project reads a header and a handful of octree nodes — a few hundred kilobytes regardless of whether the file is 40 MB or 4 GB. Adding an unindexed one blocks on a background task whose duration is proportional to the file size, and a script that calls ",[18,256,257],{},"isValid()"," immediately afterwards may see a layer that is valid but has not finished building its index, so the first render is empty and the second is not.",[260,261,266],"pre",{"className":262,"code":263,"language":264,"meta":265,"style":265},"language-python shiki shiki-themes github-dark","from qgis.core import QgsPointCloudLayer\n\ncloud = QgsPointCloudLayer(\"\u002Fdata\u002Flidar\u002Ftile_0345.copc.laz\", \"tile_0345\", \"copc\")\nprint(cloud.isValid(), cloud.pointCount())\nprint(cloud.crs().authid(), cloud.extent().toString(0))\n","python","",[18,267,268,286,293,323,333],{"__ignoreMap":265},[269,270,272,276,280,283],"span",{"class":102,"line":271},1,[269,273,275],{"class":274},"snl16","from",[269,277,279],{"class":278},"s95oV"," qgis.core ",[269,281,282],{"class":274},"import",[269,284,285],{"class":278}," QgsPointCloudLayer\n",[269,287,289],{"class":102,"line":288},2,[269,290,292],{"emptyLinePlaceholder":291},true,"\n",[269,294,296,299,302,305,309,312,315,317,320],{"class":102,"line":295},3,[269,297,298],{"class":278},"cloud ",[269,300,301],{"class":274},"=",[269,303,304],{"class":278}," QgsPointCloudLayer(",[269,306,308],{"class":307},"sU2Wk","\"\u002Fdata\u002Flidar\u002Ftile_0345.copc.laz\"",[269,310,311],{"class":278},", ",[269,313,314],{"class":307},"\"tile_0345\"",[269,316,311],{"class":278},[269,318,319],{"class":307},"\"copc\"",[269,321,322],{"class":278},")\n",[269,324,326,330],{"class":102,"line":325},4,[269,327,329],{"class":328},"sDLfK","print",[269,331,332],{"class":278},"(cloud.isValid(), cloud.pointCount())\n",[269,334,336,338,341,343],{"class":102,"line":335},5,[269,337,329],{"class":328},[269,339,340],{"class":278},"(cloud.crs().authid(), cloud.extent().toString(",[269,342,51],{"class":328},[269,344,345],{"class":278},"))\n",[14,347,348,352,353,356,357,360,361,364,365,368,369,371,372,374,375,378],{},[349,350,351],"strong",{},"Breakdown:"," The third argument is the provider key and it is not optional the way it is for a file-based vector layer — ",[18,354,355],{},"copc"," for a Cloud Optimised Point Cloud, ",[18,358,359],{},"ept"," for an Entwine index (you pass the ",[18,362,363],{},"ept.json","), and ",[18,366,367],{},"pdal"," for a raw ",[18,370,237],{},"\u002F",[18,373,20],{}," that has to be indexed on the fly. Passing the wrong key gives you an invalid layer with an unhelpful message, so it is worth branching on the file extension rather than guessing. ",[18,376,377],{},"pointCount()"," reads the header and is instant even on a billion-point file.",[213,380,382],{"id":381},"reading-the-header-before-you-read-the-points","Reading the header before you read the points",[14,384,385],{},"Almost every point cloud bug is a mismatch between what you assumed the file contains and what it actually contains: the classification scheme is non-standard, the intensity is 8-bit rather than 16-bit, the vertical datum is not what the metadata claims, or the file is simply empty over your area of interest. All of that is answerable from the header and the index statistics without touching a point.",[260,387,389],{"className":262,"code":388,"language":264,"meta":265,"style":265},"attributes = cloud.attributes()\nfor attribute in attributes.attributes():\n    print(attribute.name(), attribute.size(), \"bytes\")\n\nstats = cloud.statistics()\nprint(\"Z range\", stats.minimum(\"Z\"), stats.maximum(\"Z\"))\nprint(\"classes present\", sorted(stats.classesOf(\"Classification\")))\n",[18,390,391,401,415,428,432,442,466],{"__ignoreMap":265},[269,392,393,396,398],{"class":102,"line":271},[269,394,395],{"class":278},"attributes ",[269,397,301],{"class":274},[269,399,400],{"class":278}," cloud.attributes()\n",[269,402,403,406,409,412],{"class":102,"line":288},[269,404,405],{"class":274},"for",[269,407,408],{"class":278}," attribute ",[269,410,411],{"class":274},"in",[269,413,414],{"class":278}," attributes.attributes():\n",[269,416,417,420,423,426],{"class":102,"line":295},[269,418,419],{"class":328},"    print",[269,421,422],{"class":278},"(attribute.name(), attribute.size(), ",[269,424,425],{"class":307},"\"bytes\"",[269,427,322],{"class":278},[269,429,430],{"class":102,"line":325},[269,431,292],{"emptyLinePlaceholder":291},[269,433,434,437,439],{"class":102,"line":335},[269,435,436],{"class":278},"stats ",[269,438,301],{"class":274},[269,440,441],{"class":278}," cloud.statistics()\n",[269,443,445,447,450,453,456,459,462,464],{"class":102,"line":444},6,[269,446,329],{"class":328},[269,448,449],{"class":278},"(",[269,451,452],{"class":307},"\"Z range\"",[269,454,455],{"class":278},", stats.minimum(",[269,457,458],{"class":307},"\"Z\"",[269,460,461],{"class":278},"), stats.maximum(",[269,463,458],{"class":307},[269,465,345],{"class":278},[269,467,469,471,473,476,478,481,484,487],{"class":102,"line":468},7,[269,470,329],{"class":328},[269,472,449],{"class":278},[269,474,475],{"class":307},"\"classes present\"",[269,477,311],{"class":278},[269,479,480],{"class":328},"sorted",[269,482,483],{"class":278},"(stats.classesOf(",[269,485,486],{"class":307},"\"Classification\"",[269,488,489],{"class":278},")))\n",[14,491,492,494,495,498,499,502,503,505,506,311,509,311,512,311,515,311,518,521,522,525,526,529,530,533],{},[349,493,351],{}," ",[18,496,497],{},"attributes()"," returns a ",[18,500,501],{},"QgsPointCloudAttributeCollection","; its own ",[18,504,497],{}," method gives the list, and every LAS file will have at least ",[18,507,508],{},"X",[18,510,511],{},"Y",[18,513,514],{},"Z",[18,516,517],{},"Classification",[18,519,520],{},"Intensity"," and ",[18,523,524],{},"ReturnNumber",". ",[18,527,528],{},"statistics()"," is computed during indexing and cached, so it is cheap for COPC and EPT and expensive the first time for an unindexed file. ",[18,531,532],{},"classesOf()"," is the single most useful call in this whole guide: it tells you whether the survey was actually classified, or whether every point is class 1 (unassigned) and your ground filter is about to return nothing.",[14,535,536,537,540],{},"Two header fields deserve a specific check in any script that will run unattended. The first is the CRS. LAS files carry their projection in a variable-length record that older producers wrote inconsistently, and QGIS reports an empty ",[18,538,539],{},"authid()"," when it cannot make sense of it — at which point the layer lands wherever the project CRS happens to put it, which is usually the Gulf of Guinea. The second is the vertical datum, which the LAS header does not really model at all: a file whose Z values are ellipsoidal heights and a file whose Z values are orthometric heights look identical to the API and differ by tens of metres on the ground.",[260,542,544],{"className":262,"code":543,"language":264,"meta":265,"style":265},"if not cloud.crs().isValid():\n    raise SystemExit(\"no CRS on the LAS header — run pdal:assignprojection first\")\n\nif cloud.pointCount() == 0:\n    raise SystemExit(\"empty tile — the survey boundary probably does not cover this cell\")\n",[18,545,546,557,572,576,592],{"__ignoreMap":265},[269,547,548,551,554],{"class":102,"line":271},[269,549,550],{"class":274},"if",[269,552,553],{"class":274}," not",[269,555,556],{"class":278}," cloud.crs().isValid():\n",[269,558,559,562,565,567,570],{"class":102,"line":288},[269,560,561],{"class":274},"    raise",[269,563,564],{"class":328}," SystemExit",[269,566,449],{"class":278},[269,568,569],{"class":307},"\"no CRS on the LAS header — run pdal:assignprojection first\"",[269,571,322],{"class":278},[269,573,574],{"class":102,"line":295},[269,575,292],{"emptyLinePlaceholder":291},[269,577,578,580,583,586,589],{"class":102,"line":325},[269,579,550],{"class":274},[269,581,582],{"class":278}," cloud.pointCount() ",[269,584,585],{"class":274},"==",[269,587,588],{"class":328}," 0",[269,590,591],{"class":278},":\n",[269,593,594,596,598,600,603],{"class":102,"line":335},[269,595,561],{"class":274},[269,597,564],{"class":328},[269,599,449],{"class":278},[269,601,602],{"class":307},"\"empty tile — the survey boundary probably does not cover this cell\"",[269,604,322],{"class":278},[14,606,607,609],{},[349,608,351],{}," Failing loudly at the top of a script is worth far more than it looks, because the alternative failure is silent and downstream. An empty tile propagates as a raster full of nodata, which propagates as a hole in a mosaic, which somebody notices three steps later in a hillshade. Both of these checks cost a header read.",[213,611,613],{"id":612},"classification-is-the-attribute-everything-hangs-off","Classification is the attribute everything hangs off",[14,615,616],{},"The ASPRS classification codes are a small integer vocabulary baked into the LAS specification, and every ground-filter, building-extraction and canopy-height workflow is a filter on that one attribute. Knowing the codes by heart saves an enormous amount of time.",[14,618,619],{},[34,620,623,626,629,632,636,642,647,685,690,717,722,726,739,744,752,756,769,773,777,780,786,789,792,796,799,804,807],{"viewBox":621,"role":37,"ariaLabel":622,"xmlns":39},"0 0 760 322","A cross-section through a LiDAR point cloud showing which ASPRS classification code applies to ground, low vegetation, high vegetation, buildings and water returns",[41,624,625],{},"The classification codes you will actually use",[45,627,628],{},"A schematic cross-section of a landscape shows returns falling into classes: class two on the bare earth surface, classes three to five rising through vegetation, class six on building roofs, class nine on water, and class seven for noise points above the scene. A filter on Classification is what separates a digital terrain model from a surface model.",[49,630],{"x":51,"y":51,"width":52,"height":631,"fill":54},"322",[73,633,635],{"x":75,"y":634,"style":77,"fill":78,"textAnchor":79},"26","One integer decides whether you get terrain or rooftops",[49,637],{"x":83,"y":638,"width":639,"height":167,"rx":63,"fill":640,"stroke":641,"style":171},"44","470","#e7e2d4","#59645f",[68,643],{"d":644,"fill":157,"stroke":645,"style":646},"M32 214 Q120 200 190 206 T330 198 T478 210","#92400e","stroke-width:3",[648,649,650,656,660,664,667,671,675,678,682],"g",{"fill":645},[651,652],"circle",{"cx":653,"cy":654,"r":655},"48","213","3",[651,657],{"cx":658,"cy":659,"r":655},"96","207",[651,661],{"cx":662,"cy":663,"r":655},"144","204",[651,665],{"cx":106,"cy":666,"r":655},"206",[651,668],{"cx":669,"cy":670,"r":655},"240","202",[651,672],{"cx":673,"cy":674,"r":655},"288","199",[651,676],{"cx":677,"cy":674,"r":655},"336",[651,679],{"cx":680,"cy":681,"r":655},"384","203",[651,683],{"cx":684,"cy":659,"r":655},"432",[73,686,689],{"x":687,"y":688,"style":99,"fill":71,"textAnchor":79},"255","234","class 2 — ground, the surface a terrain model is built from",[648,691,692,696,700,703,707,710,713],{"fill":146},[651,693],{"cx":694,"cy":695,"r":655},"92","184",[651,697],{"cx":698,"cy":699,"r":655},"108","166",[651,701],{"cx":93,"cy":702,"r":655},"152",[651,704],{"cx":705,"cy":706,"r":655},"104","140",[651,708],{"cx":203,"cy":709,"r":655},"158",[651,711],{"cx":658,"cy":712,"r":655},"126",[651,714],{"cx":715,"cy":716,"r":655},"128","176",[73,718,721],{"x":98,"y":719,"style":720,"fill":146,"textAnchor":79},"112","text-anchor:middle;font-size:10px;font-weight:bold;font-family:sans-serif","4–5 vegetation",[68,723],{"d":724,"fill":725,"stroke":112,"style":89},"M244 196 L244 150 L316 150 L316 198","#c9d6cf",[648,727,728,731,734,736],{"fill":112},[651,729],{"cx":730,"cy":85,"r":655},"252",[651,732],{"cx":733,"cy":85,"r":655},"270",[651,735],{"cx":673,"cy":85,"r":655},[651,737],{"cx":738,"cy":85,"r":655},"306",[73,740,743],{"x":741,"y":742,"style":720,"fill":112,"textAnchor":79},"280","138","6 building",[49,745],{"x":746,"y":747,"width":658,"height":748,"fill":749,"stroke":750,"style":751},"366","196","14","#dbeafe","#1e40af","stroke-width:1.5",[73,753,755],{"x":754,"y":695,"style":720,"fill":750,"textAnchor":79},"414","9 water",[648,757,758,761,765],{"fill":206},[651,759],{"cx":760,"cy":169,"r":655},"160",[651,762],{"cx":763,"cy":764,"r":655},"352","64",[651,766],{"cx":767,"cy":768,"r":655},"420","80",[73,770,772],{"x":771,"y":764,"style":720,"fill":206,"textAnchor":79},"300","7 noise — drop these first",[49,774],{"x":775,"y":638,"width":776,"height":167,"rx":63,"fill":170,"stroke":129,"style":89},"510","230",[73,778,779],{"x":196,"y":86,"style":94,"fill":129,"textAnchor":79},"what you filter for",[73,781,785],{"x":782,"y":783,"style":784,"fill":71},"530","100","font-size:10.5px;font-family:sans-serif","terrain model → 2",[73,787,788],{"x":782,"y":712,"style":784,"fill":71},"surface model → 2, 6, 5",[73,790,791],{"x":782,"y":702,"style":784,"fill":71},"canopy height → 5 minus 2",[73,793,795],{"x":782,"y":794,"style":784,"fill":71},"178","building footprints → 6",[73,797,798],{"x":782,"y":663,"style":784,"fill":71},"always exclude → 7, 18",[73,800,803],{"x":782,"y":801,"style":802,"fill":641},"228","font-size:10px;font-family:sans-serif","1 = unassigned, never useful",[49,805],{"x":203,"y":806,"width":193,"height":638,"rx":63,"fill":87,"stroke":88,"style":171},"264",[73,808,809],{"x":75,"y":204,"style":210,"fill":88,"textAnchor":79},"check the classes are present before you filter — many surveys ship class 1 only",[14,811,812],{},"Filtering on that attribute from Python does not need an algorithm at all. A point cloud layer accepts a subset string, which is applied by the reader as the index is walked, so it costs nothing and it affects rendering and every algorithm that takes the layer as input.",[260,814,816],{"className":262,"code":815,"language":264,"meta":265,"style":265},"cloud.setSubsetString(\"Classification = 2\")\nprint(cloud.subsetString())\ncloud.setSubsetString(\"\")  # back to every point\n",[18,817,818,828,835],{"__ignoreMap":265},[269,819,820,823,826],{"class":102,"line":271},[269,821,822],{"class":278},"cloud.setSubsetString(",[269,824,825],{"class":307},"\"Classification = 2\"",[269,827,322],{"class":278},[269,829,830,832],{"class":102,"line":288},[269,831,329],{"class":328},[269,833,834],{"class":278},"(cloud.subsetString())\n",[269,836,837,839,842,845],{"class":102,"line":295},[269,838,822],{"class":278},[269,840,841],{"class":307},"\"\"",[269,843,844],{"class":278},")  ",[269,846,848],{"class":847},"sjoCn","# back to every point\n",[14,850,851,853,854,857,858,861,862,865,866,869,870,874],{},[349,852,351],{}," The syntax is the QGIS expression subset supported by the point cloud providers — comparisons and boolean operators on attribute names, so ",[18,855,856],{},"Classification IN (2, 9) AND Z \u003C 340"," is valid and ",[18,859,860],{},"Classification = 'ground'"," is not. Setting it to an empty string clears it. Unlike a vector subset string this does not persist into an exported file: it is a view, and if you want a filtered ",[228,863,864],{},"file"," you need ",[18,867,868],{},"pdal:filter",", covered in ",[26,871,873],{"href":872},"\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Ffilter-and-classify-point-cloud-pyqgis\u002F","filtering and classifying a point cloud",".",[213,876,878],{"id":877},"the-pdal-algorithms-are-the-stable-api","The PDAL algorithms are the stable API",[14,880,881,882,885,886,889,890,893,894,897],{},"QGIS 3.32 added a family of Processing algorithms with the ",[18,883,884],{},"pdal:"," prefix, and by 3.34 LTR they cover essentially everything a production LiDAR pipeline needs. They matter for a reason beyond convenience: the low-level index classes (",[18,887,888],{},"QgsPointCloudIndex",", the node identifiers, the request\u002Fblock objects) have changed shape across several releases, while the algorithm identifiers and parameter names have not. Code written against ",[18,891,892],{},"pdal:exportraster"," in 3.32 still runs on 3.44. Code written against ",[18,895,896],{},"IndexedPointCloudNode"," in 3.32 does not.",[260,899,901],{"className":262,"code":900,"language":264,"meta":265,"style":265},"import processing\n\ndem = processing.run(\"pdal:exportraster\", {\n    \"INPUT\": \"\u002Fdata\u002Flidar\u002Ftile_0345.copc.laz\",\n    \"RESOLUTION\": 1.0,\n    \"TILE_SIZE\": 1000,\n    \"FILTER_EXPRESSION\": \"Classification == 2\",\n    \"OUTPUT\": \"\u002Fdata\u002Foutput\u002Fdtm_0345.tif\",\n})[\"OUTPUT\"]\n",[18,902,903,910,914,930,943,955,967,979,992],{"__ignoreMap":265},[269,904,905,907],{"class":102,"line":271},[269,906,282],{"class":274},[269,908,909],{"class":278}," processing\n",[269,911,912],{"class":102,"line":288},[269,913,292],{"emptyLinePlaceholder":291},[269,915,916,919,921,924,927],{"class":102,"line":295},[269,917,918],{"class":278},"dem ",[269,920,301],{"class":274},[269,922,923],{"class":278}," processing.run(",[269,925,926],{"class":307},"\"pdal:exportraster\"",[269,928,929],{"class":278},", {\n",[269,931,932,935,938,940],{"class":102,"line":325},[269,933,934],{"class":307},"    \"INPUT\"",[269,936,937],{"class":278},": ",[269,939,308],{"class":307},[269,941,942],{"class":278},",\n",[269,944,945,948,950,953],{"class":102,"line":335},[269,946,947],{"class":307},"    \"RESOLUTION\"",[269,949,937],{"class":278},[269,951,952],{"class":328},"1.0",[269,954,942],{"class":278},[269,956,957,960,962,965],{"class":102,"line":444},[269,958,959],{"class":307},"    \"TILE_SIZE\"",[269,961,937],{"class":278},[269,963,964],{"class":328},"1000",[269,966,942],{"class":278},[269,968,969,972,974,977],{"class":102,"line":468},[269,970,971],{"class":307},"    \"FILTER_EXPRESSION\"",[269,973,937],{"class":278},[269,975,976],{"class":307},"\"Classification == 2\"",[269,978,942],{"class":278},[269,980,982,985,987,990],{"class":102,"line":981},8,[269,983,984],{"class":307},"    \"OUTPUT\"",[269,986,937],{"class":278},[269,988,989],{"class":307},"\"\u002Fdata\u002Foutput\u002Fdtm_0345.tif\"",[269,991,942],{"class":278},[269,993,995,998,1001],{"class":102,"line":994},9,[269,996,997],{"class":278},"})[",[269,999,1000],{"class":307},"\"OUTPUT\"",[269,1002,1003],{"class":278},"]\n",[14,1005,1006,494,1008,1011,1012,1014,1015,1018],{},[349,1007,351],{},[18,1009,1010],{},"FILTER_EXPRESSION"," here is PDAL syntax, not QGIS expression syntax — note the doubled ",[18,1013,585],{},". That inconsistency between the layer subset string and the algorithm filter is the single most common trip-up in this area, and it fails quietly by matching nothing rather than raising. ",[18,1016,1017],{},"TILE_SIZE"," controls how the algorithm chunks its work; leaving it at the default is fine until you hit memory pressure on a very large input.",[14,1020,1021,1022,1025,1026,1029,1030,874],{},"There is a second reason to prefer the algorithms, which only becomes obvious on a long job: they report progress and honour cancellation through the standard ",[18,1023,1024],{},"QgsProcessingFeedback"," object. A PDAL pipeline invoked directly through a subprocess gives you neither, so a plugin that shells out has no way to draw a progress bar or to stop when the user asks. Passing a feedback object into ",[18,1027,1028],{},"processing.run()"," gives both for free, and it is the same object the rest of the site uses for ",[26,1031,1033],{"href":1032},"\u002Fspatial-data-processing-automation\u002Fchaining-processing-algorithms\u002Fhandle-processing-feedback-and-errors-pyqgis\u002F","handling processing feedback and errors",[14,1035,1036,1037,1039,1040,1043],{},"The algorithms also normalise a genuine awkwardness in PDAL itself: most of them accept either a file path or a loaded ",[18,1038,221],{}," as ",[18,1041,1042],{},"INPUT",", and they resolve a layer back to its underlying file for you. That means the same call works in a script that has just built a layer and in a batch job that only has paths, without a branch.",[14,1045,1046,1047,311,1050,311,1053,311,1056,311,1059,311,1061,311,1064,311,1067,311,1070,311,1073,311,1076,311,1079,311,1081,521,1084,525,1087,1091],{},"The full list is worth skimming once: ",[18,1048,1049],{},"pdal:info",[18,1051,1052],{},"pdal:convertformat",[18,1054,1055],{},"pdal:reproject",[18,1057,1058],{},"pdal:clip",[18,1060,868],{},[18,1062,1063],{},"pdal:merge",[18,1065,1066],{},"pdal:tile",[18,1068,1069],{},"pdal:thinbyradius",[18,1071,1072],{},"pdal:thinbydecimate",[18,1074,1075],{},"pdal:boundary",[18,1077,1078],{},"pdal:density",[18,1080,892],{},[18,1082,1083],{},"pdal:exportrastertin",[18,1085,1086],{},"pdal:exportvector",[26,1088,1090],{"href":1089},"\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Frun-pdal-algorithms-from-pyqgis\u002F","Running the PDAL algorithms from PyQGIS"," works through the parameters that are easy to get wrong.",[213,1093,1095],{"id":1094},"turning-points-into-products","Turning points into products",[14,1097,1098],{},"Nothing downstream in a GIS consumes points directly. A hydrologist wants a DEM, a planner wants building footprints, a forester wants a canopy height model. Every one of those is a conversion, and each conversion is a decision about what to do with the cells that have no points in them.",[14,1100,1101],{},[34,1102,1105,1108,1111,1114,1121,1124,1129,1134,1138,1141,1158,1161,1164,1168,1171,1174,1191,1194,1198,1202,1205,1210,1215,1220,1223,1228,1231,1234,1238],{"viewBox":1103,"role":37,"ariaLabel":1104,"xmlns":39},"0 0 760 340","Three conversions from the same point cloud: ground points gridded into a terrain model, all first returns gridded into a surface model, and the difference of the two producing a canopy or building height model",[41,1106,1107],{},"The same cloud, three products",[45,1109,1110],{},"Filtering to ground returns and gridding produces a digital terrain model. Gridding the highest return in each cell produces a digital surface model. Subtracting terrain from surface produces a normalised height model in which trees and roofs stand at their true height above the ground.",[49,1112],{"x":51,"y":51,"width":52,"height":1113,"fill":54},"340",[56,1115,1116],{},[59,1117,1119],{"id":1118,"viewBox":62,"refX":63,"refY":64,"markerWidth":65,"markerHeight":65,"orient":66},"pclProdArrow",[68,1120],{"d":70,"fill":71},[73,1122,1123],{"x":75,"y":634,"style":77,"fill":78,"textAnchor":79},"Terrain, surface and the difference between them",[49,1125],{"x":1126,"y":653,"width":1127,"height":716,"rx":1128,"fill":128,"stroke":129,"style":89},"22","222","10",[73,1130,1133],{"x":1131,"y":1132,"style":94,"fill":129,"textAnchor":79},"133","74","terrain model (DTM)",[73,1135,1137],{"x":1131,"y":658,"style":1136,"fill":71,"textAnchor":79},"text-anchor:middle;font-size:10px;font-family:sans-serif","Classification == 2, mean per cell",[68,1139],{"d":1140,"fill":157,"stroke":645,"style":646},"M44 186 Q92 172 132 178 T222 182",[648,1142,1143,1147,1149,1152,1154],{"fill":645},[651,1144],{"cx":1145,"cy":695,"r":1146},"52","2.6",[651,1148],{"cx":694,"cy":716,"r":1146},[651,1150],{"cx":1151,"cy":794,"r":1146},"132",[651,1153],{"cx":144,"cy":716,"r":1146},[651,1155],{"cx":1156,"cy":1157,"r":1146},"212","181",[73,1159,1160],{"x":1131,"y":168,"style":1136,"fill":641,"textAnchor":79},"bare earth, buildings removed",[49,1162],{"x":1163,"y":653,"width":1127,"height":716,"rx":1128,"fill":111,"stroke":112,"style":89},"268",[73,1165,1167],{"x":1166,"y":1132,"style":94,"fill":112,"textAnchor":79},"379","surface model (DSM)",[73,1169,1170],{"x":1166,"y":658,"style":1136,"fill":71,"textAnchor":79},"all returns, maximum per cell",[68,1172],{"d":1173,"fill":157,"stroke":112,"style":646},"M290 186 Q318 172 336 176 L336 140 L376 140 L376 178 Q404 158 424 166 T468 180",[648,1175,1176,1180,1182,1184,1188],{"fill":112},[651,1177],{"cx":1178,"cy":1179,"r":1146},"298","182",[651,1181],{"cx":677,"cy":706,"r":1146},[651,1183],{"cx":126,"cy":706,"r":1146},[651,1185],{"cx":1186,"cy":1187,"r":1146},"412","162",[651,1189],{"cx":1190,"cy":794,"r":1146},"456",[73,1192,1193],{"x":1166,"y":168,"style":1136,"fill":641,"textAnchor":79},"roofs and canopy included",[49,1195],{"x":1196,"y":653,"width":1197,"height":716,"rx":1128,"fill":87,"stroke":88,"style":89},"514","224",[73,1199,1201],{"x":1200,"y":1132,"style":94,"fill":88,"textAnchor":79},"626","height model (DSM − DTM)",[73,1203,1204],{"x":1200,"y":658,"style":1136,"fill":71,"textAnchor":79},"raster calculator, one expression",[102,1206],{"x1":1207,"y1":1208,"x2":1209,"y2":1208,"stroke":645,"style":89},"536","186","726",[49,1211],{"x":1212,"y":1213,"width":1214,"height":1214,"fill":725,"stroke":112,"style":171},"572","146","40",[49,1216],{"x":1217,"y":709,"width":1218,"height":76,"fill":1219,"stroke":146,"style":171},"648","34","#e8efe6",[73,1221,1222],{"x":1200,"y":168,"style":1136,"fill":641,"textAnchor":79},"heights above ground, terrain flat",[102,1224],{"x1":1225,"y1":1226,"x2":806,"y2":1226,"stroke":71,"style":1227},"244","136","stroke-width:2;marker-end:url(#pclProdArrow)",[102,1229],{"x1":1230,"y1":1226,"x2":775,"y2":1226,"stroke":71,"style":1227},"490",[49,1232],{"x":768,"y":730,"width":1233,"height":86,"rx":63,"fill":170,"stroke":206,"style":171},"600",[73,1235,1237],{"x":75,"y":1236,"style":94,"fill":206,"textAnchor":79},"278","the cells with no points in them decide how the output looks",[73,1239,1241],{"x":75,"y":1240,"style":99,"fill":71,"textAnchor":79},"302","under a dense canopy a 0.5 m ground grid is mostly holes; 1 m or 2 m is usually the honest resolution",[14,1243,1244,1245,874],{},"Choosing the output resolution is the decision that matters, and the rule of thumb is arithmetic rather than taste: a grid cell needs at least a few points in it to produce a value you can defend. Divide the survey's stated point density into one and take the square root. A 4 points-per-square-metre survey supports a 0.5 m grid over open ground and, once you filter to ground returns under trees, realistically a 1 m or 2 m one. Asking for 0.25 m gives you a raster full of nodata holes and a slope map full of artefacts, as described in ",[26,1246,1248],{"href":1247},"\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Fcreate-dem-from-point-cloud-pyqgis\u002F","creating a DEM from a point cloud",[14,1250,1251,1252,1256,1257,1261,1262,1266],{},"Once you have the rasters, the rest is ordinary raster work: ",[26,1253,1255],{"href":1254},"\u002Fspatial-data-processing-automation\u002Fraster-analysis-workflows\u002Fraster-calculator-pyqgis\u002F","the raster calculator"," subtracts one from the other, ",[26,1258,1260],{"href":1259},"\u002Fspatial-data-processing-automation\u002Fraster-analysis-workflows\u002Fzonal-statistics-pyqgis\u002F","zonal statistics"," summarises the result inside parcels, and ",[26,1263,1265],{"href":1264},"\u002Fspatial-data-processing-automation\u002Fterrain-and-interpolation-analysis\u002Fgenerate-slope-aspect-hillshade-pyqgis\u002F","slope, aspect and hillshade"," turn the terrain model into something readable.",[213,1268,1270],{"id":1269},"rendering-without-melting-the-canvas","Rendering without melting the canvas",[14,1272,1273,1274,1277,1278,1281,1282,311,1285,521,1288,1291,1292,1295,1296,311,1298,311,1300,1303,1304,1307],{},"A point cloud layer has four renderers, and picking the right one is mostly about which attribute carries the meaning. ",[18,1275,1276],{},"QgsPointCloudClassifiedRenderer"," colours by classification and is the default when classes are present. ",[18,1279,1280],{},"QgsPointCloudRgbRenderer"," uses the ",[18,1283,1284],{},"Red",[18,1286,1287],{},"Green",[18,1289,1290],{},"Blue"," attributes if the survey was colourised from imagery. ",[18,1293,1294],{},"QgsPointCloudAttributeByRampRenderer"," maps any numeric attribute — ",[18,1297,514],{},[18,1299,520],{},[18,1301,1302],{},"NumberOfReturns"," — through a colour ramp. ",[18,1305,1306],{},"QgsPointCloudExtentRenderer"," draws only the tile boundary, which is what you want in an overview map with forty tiles loaded.",[260,1309,1311],{"className":262,"code":1310,"language":264,"meta":265,"style":265},"from qgis.core import QgsPointCloudClassifiedRenderer, QgsPointCloudCategory\nfrom qgis.PyQt.QtGui import QColor\n\nrenderer = QgsPointCloudClassifiedRenderer(\"Classification\")\nrenderer.setCategories([\n    QgsPointCloudCategory(2, QColor(\"#8d6e3f\"), \"ground\"),\n    QgsPointCloudCategory(5, QColor(\"#2f7a3d\"), \"high vegetation\"),\n    QgsPointCloudCategory(6, QColor(\"#8f4b4b\"), \"building\"),\n])\nrenderer.setPointSize(1.4)\ncloud.setRenderer(renderer)\ncloud.triggerRepaint()\n",[18,1312,1313,1324,1336,1340,1354,1359,1382,1400,1419,1424,1435,1441],{"__ignoreMap":265},[269,1314,1315,1317,1319,1321],{"class":102,"line":271},[269,1316,275],{"class":274},[269,1318,279],{"class":278},[269,1320,282],{"class":274},[269,1322,1323],{"class":278}," QgsPointCloudClassifiedRenderer, QgsPointCloudCategory\n",[269,1325,1326,1328,1331,1333],{"class":102,"line":288},[269,1327,275],{"class":274},[269,1329,1330],{"class":278}," qgis.PyQt.QtGui ",[269,1332,282],{"class":274},[269,1334,1335],{"class":278}," QColor\n",[269,1337,1338],{"class":102,"line":295},[269,1339,292],{"emptyLinePlaceholder":291},[269,1341,1342,1345,1347,1350,1352],{"class":102,"line":325},[269,1343,1344],{"class":278},"renderer ",[269,1346,301],{"class":274},[269,1348,1349],{"class":278}," QgsPointCloudClassifiedRenderer(",[269,1351,486],{"class":307},[269,1353,322],{"class":278},[269,1355,1356],{"class":102,"line":335},[269,1357,1358],{"class":278},"renderer.setCategories([\n",[269,1360,1361,1364,1367,1370,1373,1376,1379],{"class":102,"line":444},[269,1362,1363],{"class":278},"    QgsPointCloudCategory(",[269,1365,1366],{"class":328},"2",[269,1368,1369],{"class":278},", QColor(",[269,1371,1372],{"class":307},"\"#8d6e3f\"",[269,1374,1375],{"class":278},"), ",[269,1377,1378],{"class":307},"\"ground\"",[269,1380,1381],{"class":278},"),\n",[269,1383,1384,1386,1388,1390,1393,1395,1398],{"class":102,"line":468},[269,1385,1363],{"class":278},[269,1387,64],{"class":328},[269,1389,1369],{"class":278},[269,1391,1392],{"class":307},"\"#2f7a3d\"",[269,1394,1375],{"class":278},[269,1396,1397],{"class":307},"\"high vegetation\"",[269,1399,1381],{"class":278},[269,1401,1402,1404,1407,1409,1412,1414,1417],{"class":102,"line":981},[269,1403,1363],{"class":278},[269,1405,1406],{"class":328},"6",[269,1408,1369],{"class":278},[269,1410,1411],{"class":307},"\"#8f4b4b\"",[269,1413,1375],{"class":278},[269,1415,1416],{"class":307},"\"building\"",[269,1418,1381],{"class":278},[269,1420,1421],{"class":102,"line":994},[269,1422,1423],{"class":278},"])\n",[269,1425,1427,1430,1433],{"class":102,"line":1426},10,[269,1428,1429],{"class":278},"renderer.setPointSize(",[269,1431,1432],{"class":328},"1.4",[269,1434,322],{"class":278},[269,1436,1438],{"class":102,"line":1437},11,[269,1439,1440],{"class":278},"cloud.setRenderer(renderer)\n",[269,1442,1444],{"class":102,"line":1443},12,[269,1445,1446],{"class":278},"cloud.triggerRepaint()\n",[14,1448,1449,1451,1452,1455,1456,1459],{},[349,1450,351],{}," Categories you do not list are not drawn, which is a fast way to get a classification-filtered view without touching the subset string. ",[18,1453,1454],{},"QgsPointCloudClassifiedRenderer.defaultCategories()"," returns the full ASPRS set with the standard colours if you would rather start from that and edit. Point size is in millimetres by default; ",[18,1457,1458],{},"setPointSizeUnit()"," switches it to pixels or points, and on a dense cloud the difference between 1 mm and 3 mm is the difference between a readable surface and a solid block of colour.",[14,1461,1462,1463,1467,1468,874],{},"The other half of performance is the point budget — the maximum number of points QGIS will draw in one canvas refresh. It is a global setting rather than a layer property, and a script that renders a cloud to an image needs to raise it or accept a sparse picture. That, along with the interaction with ",[26,1464,1466],{"href":1465},"\u002Fpyqgis-cartography-visualization\u002Fmap-canvas-and-image-export\u002Frender-layer-to-image-without-gui-pyqgis\u002F","rendering a layer to an image without the GUI",", is covered in ",[26,1469,1471],{"href":1470},"\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Fstyle-point-cloud-renderer-pyqgis\u002F","styling a point cloud renderer",[213,1473,1475],{"id":1474},"scaling-past-one-tile","Scaling past one tile",[14,1477,1478],{},"A survey is never one file. The moment you have forty of them, three things change: you want a single logical layer rather than forty entries in the tree, you want to process only the tiles that intersect your area of interest, and you want the intermediate products to land somewhere predictable.",[14,1480,1481,1482,1485,1486,1490,1491,874],{},"The virtual point cloud format — a small ",[18,1483,1484],{},".vpc"," JSON file listing member clouds with their extents — solves the first two at once. QGIS opens it as a single layer, and the reader consults the member extents so that a clipped operation touches only the tiles it must. Building one is a single algorithm call, and it is almost always the right first step of a multi-tile pipeline. ",[26,1487,1489],{"href":1488},"\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Fclip-and-tile-point-clouds-pyqgis\u002F","Clipping and tiling point clouds"," covers building and consuming them, and the loop that walks a folder is the same loop as ",[26,1492,1494],{"href":1493},"\u002Fspatial-data-processing-automation\u002Fbatch-processing-with-pyqgis\u002Frun-algorithm-over-folder-of-files-pyqgis\u002F","running an algorithm over a folder of files",[213,1496,1498],{"id":1497},"where-point-clouds-meet-the-rest-of-the-project","Where point clouds meet the rest of the project",[14,1500,1501,1502,1505],{},"A point cloud is unusual in a QGIS project because it participates in almost nothing that vector and raster layers participate in. It has no attribute table dialog, no joins, no relations, no editing, no labelling, and it is invisible to most of the ",[18,1503,1504],{},"native:"," algorithms. Everything it contributes to a project it contributes through a conversion.",[14,1507,1508,1509,1512,1513,1516],{},"There are three bridges worth knowing. The first is elevation: a point cloud layer has elevation properties (",[18,1510,1511],{},"cloud.elevationProperties()",") carrying a Z scale and Z offset, which is how you correct a file delivered in feet inside a metric project without rewriting it, and which is what the elevation profile tool reads. The second is the 3D view, where ",[18,1514,1515],{},"QgsPointCloudLayer3DRenderer"," gives the layer a place in a 3D map scene alongside extruded vector layers and a terrain surface. The third — the one that carries the most weight in practice — is the export back to vector or raster, after which the data behaves like any other layer and the whole rest of this site applies to it.",[14,1518,1519,1520,1522,1523,521,1527,1531],{},"That framing is useful because it tells you where to stop. If a task involves per-feature attributes, joins, styling by category or anything that expects a feature id, the answer is not to reach deeper into the point cloud API; it is to export the points you care about and do the work on the resulting layer. ",[18,1521,1086],{}," produces a normal point layer with the LAS attributes as fields, at which point ",[26,1524,1526],{"href":1525},"\u002Fspatial-data-processing-automation\u002Fvector-data-manipulation\u002Fspatial-join-points-in-polygons-pyqgis\u002F","spatial joins",[26,1528,1530],{"href":1529},"\u002Fspatial-data-processing-automation\u002Fattribute-tables-and-field-management\u002F","attribute management"," work exactly as they do everywhere else.",[213,1533,1535],{"id":1534},"failure-modes-worth-recognising-on-sight","Failure modes worth recognising on sight",[14,1537,1538],{},"Point cloud problems tend to look like nothing at all — an empty canvas, a blank raster, a job that takes four hours — rather than like an exception, so it is worth being able to recognise the handful that account for most of them.",[14,1540,1541,1542,1544,1545,1547],{},"A cloud that renders as a flat grey rectangle is usually the extent renderer, which QGIS falls back to when a layer is loaded but its index is not yet available. A cloud that renders as a thin ribbon along one edge of its extent is a CRS problem: some points carry sensible coordinates and the rest are at the origin, typically because a merge combined tiles in two projections. A DEM export that produces a raster of nodata means the filter expression matched nothing, and nine times in ten the reason is the ",[18,1543,301],{}," versus ",[18,1546,585],{}," difference between QGIS and PDAL expression syntax. A job that runs for hours on a small area is nearly always reading unindexed files, or reading a whole tiled survey because no virtual point cloud told it which tiles overlap the area of interest.",[14,1549,1550,1551,1553],{},"The diagnostic that resolves most of these in one step is ",[18,1552,1049],{},", which reports the header, the CRS, the point count and the per-attribute statistics for a file without loading it into the project. Running it first, and logging what it says, converts a class of silent failures into a line in a log file.",[213,1555,1557],{"id":1556},"key-takeaways","Key takeaways",[1559,1560,1561,1574,1581,1592,1598,1601],"ul",{},[1562,1563,1564,1566,1567,311,1569,238,1571,1573],"li",{},[18,1565,221],{}," needs an explicit provider key: ",[18,1568,355],{},[18,1570,359],{},[18,1572,367],{},". Index once, up front, rather than paying for it on every run.",[1562,1575,1576,1577,1580],{},"Read ",[18,1578,1579],{},"statistics().classesOf(\"Classification\")"," before writing any filter. A great many surveys ship entirely as class 1.",[1562,1582,1583,1584,1587,1588,1591],{},"The layer subset string uses QGIS expression syntax (",[18,1585,1586],{},"Classification = 2","); the PDAL algorithms use PDAL syntax (",[18,1589,1590],{},"Classification == 2","). Mixing them fails silently.",[1562,1593,1594,1595,1597],{},"Prefer the ",[18,1596,884],{}," Processing algorithms over the low-level index classes — the algorithm identifiers are stable across releases and the C++ index API is not.",[1562,1599,1600],{},"Choose output resolution from point density, not from what looks nice. Ground returns under canopy are far sparser than the survey's headline figure.",[1562,1602,1603],{},"Build a virtual point cloud over a tiled survey so downstream operations read only the tiles they need.",[213,1605,1607],{"id":1606},"frequently-asked-questions","Frequently Asked Questions",[14,1609,1610,1613,1614,1616,1617,1620,1621,874],{},[349,1611,1612],{},"Do I need PDAL installed separately?","\nNo. The QGIS installers bundle PDAL, and the ",[18,1615,884],{}," algorithms appear in the Processing registry automatically from 3.32 onwards. If they are missing, the QGIS build is older or was compiled without PDAL support — check with ",[18,1618,1619],{},"QgsApplication.processingRegistry().algorithmById(\"pdal:info\")"," returning something other than ",[18,1622,1623],{},"None",[14,1625,1626,1629,1630,1632,1633,1636,1637,1639],{},[349,1627,1628],{},"Can I iterate individual points from Python the way I iterate features?","\nTechnically yes, through ",[18,1631,888],{}," and a ",[18,1634,1635],{},"QgsPointCloudRequest",", but the classes involved have been renamed and reshaped several times across releases and there is no supported iterator that survives a version bump. For anything you have to maintain, express the work as a ",[18,1638,884],{}," algorithm or export to a vector layer first.",[14,1641,1642,1645,1646,1649],{},[349,1643,1644],{},"Why is my cloud invisible even though the layer is valid?","\nThree usual causes: the point budget is exhausted by another dense layer, the renderer has categories that do not match the classes present, or the CRS is missing from the LAS header so the layer sits in a different part of the world from everything else. ",[18,1647,1648],{},"pdal:assignprojection"," fixes the third.",[14,1651,1652,1655],{},[349,1653,1654],{},"How big is the index compared to the source file?","\nA COPC file is a reorganised LAZ and is generally within about ten per cent of the original size, because the compression is the same. An EPT index is a directory of many small files and is typically a little larger. Neither is a copy on top of the original — COPC replaces it.",[14,1657,1658,1661,1662,1664,1665,1667],{},[349,1659,1660],{},"Is there a way to edit classifications from PyQGIS?","\nNot in place. Point cloud layers are read-only in QGIS. You reclassify by writing a new file with ",[18,1663,868],{}," or an assignment expression, which is what ",[26,1666,873],{"href":872}," walks through.",[213,1669,1671],{"id":1670},"related","Related",[1559,1673,1674,1680,1686,1691,1696,1701,1706,1711,1717],{},[1562,1675,1676,1679],{},[26,1677,1678],{"href":28},"Spatial Data Processing & Automation with PyQGIS"," — the section this guide belongs to",[1562,1681,1682],{},[26,1683,1685],{"href":1684},"\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Fload-point-cloud-layer-pyqgis\u002F","Load a Point Cloud Layer in PyQGIS",[1562,1687,1688],{},[26,1689,1690],{"href":1089},"Run the PDAL Algorithms from PyQGIS",[1562,1692,1693],{},[26,1694,1695],{"href":872},"Filter and Classify a Point Cloud in PyQGIS",[1562,1697,1698],{},[26,1699,1700],{"href":1247},"Create a DEM from a Point Cloud in PyQGIS",[1562,1702,1703],{},[26,1704,1705],{"href":1470},"Style a Point Cloud Renderer in PyQGIS",[1562,1707,1708],{},[26,1709,1710],{"href":1488},"Clip and Tile Point Clouds in PyQGIS",[1562,1712,1713],{},[26,1714,1716],{"href":1715},"\u002Fspatial-data-processing-automation\u002Fterrain-and-interpolation-analysis\u002F","Terrain & Interpolation Analysis in PyQGIS",[1562,1718,1719],{},[26,1720,1722],{"href":1721},"\u002Fspatial-data-processing-automation\u002Fraster-analysis-workflows\u002F","Raster Analysis Workflows in PyQGIS",[1724,1725,1726],"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);}html pre.shiki code .sjoCn, html code.shiki .sjoCn{--shiki-default:#9AA79F}",{"title":265,"searchDepth":288,"depth":288,"links":1728},[1729,1730,1731,1732,1733,1734,1735,1736,1737,1738,1739,1740],{"id":215,"depth":288,"text":216},{"id":381,"depth":288,"text":382},{"id":612,"depth":288,"text":613},{"id":877,"depth":288,"text":878},{"id":1094,"depth":288,"text":1095},{"id":1269,"depth":288,"text":1270},{"id":1474,"depth":288,"text":1475},{"id":1497,"depth":288,"text":1498},{"id":1534,"depth":288,"text":1535},{"id":1556,"depth":288,"text":1557},{"id":1606,"depth":288,"text":1607},{"id":1670,"depth":288,"text":1671},"Load COPC, EPT and LAZ point clouds in PyQGIS, read classification and statistics, filter and clip with the PDAL algorithms, and turn points into rasters and vectors.","md",{"slug":1744,"type":1745,"breadcrumb":1746,"datePublished":1747,"dateModified":1747},"point-cloud-and-lidar-workflows","guide","Point Cloud & LiDAR Workflows","2026-09-04","\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows",{"title":5,"description":1741},"spatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Findex","wbdGQXsUsANpvq3q2U1eI7ui9Zdfj3Cb7tWc9dXIGh4",1788563855592]