[{"data":1,"prerenderedAt":1346},["ShallowReactive",2],{"doc:\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Ffilter-and-classify-point-cloud-pyqgis":3},{"id":4,"title":5,"body":6,"description":1335,"extension":1336,"meta":1337,"navigation":226,"path":1342,"seo":1343,"stem":1344,"__hash__":1345},"docs\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Ffilter-and-classify-point-cloud-pyqgis\u002Findex.md","Filter and Classify a Point Cloud in PyQGIS",{"type":7,"value":8,"toc":1322},"minimark",[9,13,17,26,158,163,185,189,192,352,374,378,381,461,492,495,499,634,637,677,695,698,753,764,768,771,785,839,848,854,1005,1014,1018,1028,1035,1140,1144,1156,1160,1228,1232,1238,1242,1248,1254,1277,1283,1287,1318],[10,11,5],"h1",{"id":12},"filter-and-classify-a-point-cloud-in-pyqgis",[14,15,16],"p",{},"Almost every LiDAR task begins by throwing points away. A terrain model wants ground returns, a canopy study wants high vegetation, a building extraction wants class 6, and every one of them wants the noise gone first. The mechanics are simple; what trips people up is that QGIS gives you two entirely separate filtering mechanisms with two different expression languages, and that a great many deliveries are not classified at all.",[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 choosing between a subset string and a written file, the noise removal that should come first, checking what classes actually exist, and the honest options when the answer is \"none of them\".",[14,27,28],{},[29,30,35,39,43,50,67,76,85,90,95,98,107,111,117,121,125,130,134,138,141,144,153],"svg",{"viewBox":31,"role":32,"ariaLabel":33,"xmlns":34},"0 0 760 320","img","Two filtering routes compared: a subset string that changes only what QGIS shows and reads, and pdal:filter which writes a new smaller file that any tool can open","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[36,37,38],"title",{},"A view of the cloud, or a smaller cloud",[40,41,42],"desc",{},"Setting a subset string leaves the file untouched and changes only what the layer renders and what layer-based queries see; it is instant and reversible. Running pdal:filter reads the whole file and writes a new one containing only the matching points, which costs time and disk but produces something every other tool can open.",[44,45],"rect",{"x":46,"y":46,"width":47,"height":48,"fill":49},"0","760","320","#f6f3ea",[51,52,53],"defs",{},[54,55,62],"marker",{"id":56,"viewBox":57,"refX":58,"refY":59,"markerWidth":60,"markerHeight":60,"orient":61},"fcpRouteArrow","0 0 10 10","8","5","7","auto-start-reverse",[63,64],"path",{"d":65,"fill":66},"M0 0 L10 5 L0 10 z","#2f3b35",[68,69,75],"text",{"x":70,"y":71,"style":72,"fill":73,"textAnchor":74},"380","26","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","Filter the view, or filter the data",[44,77],{"x":78,"y":79,"width":80,"height":81,"rx":58,"fill":82,"stroke":83,"style":84},"290","46","180","48","#efeadd","#59645f","stroke-width:2.5",[68,86,89],{"x":70,"y":87,"style":88,"fill":73,"textAnchor":74},"76","text-anchor:middle;font-size:11.5px;font-weight:bold;font-family:sans-serif","the cloud on disk",[63,91],{"d":92,"fill":93,"stroke":66,"style":94},"M380 94 L380 116 L180 116 L180 142","none","stroke-width:2;marker-end:url(#fcpRouteArrow)",[63,96],{"d":97,"fill":93,"stroke":66,"style":94},"M380 94 L380 116 L580 116 L580 142",[44,99],{"x":100,"y":101,"width":102,"height":103,"rx":104,"fill":105,"stroke":106,"style":84},"40","148","280","120","10","#eef7f4","#0f766e",[68,108,110],{"x":80,"y":109,"style":88,"fill":106,"textAnchor":74},"174","setSubsetString",[68,112,116],{"x":113,"y":114,"style":115,"fill":66},"60","200","font-size:10.5px;font-family:sans-serif","instant, reversible",[68,118,120],{"x":113,"y":119,"style":115,"fill":66},"222","affects display and layer reads",[68,122,124],{"x":113,"y":123,"style":115,"fill":83},"244","invisible to pdal: algorithms",[44,126],{"x":127,"y":101,"width":102,"height":103,"rx":104,"fill":128,"stroke":129,"style":84},"440","#fdf2e2","#b45309",[68,131,133],{"x":132,"y":109,"style":88,"fill":129,"textAnchor":74},"580","pdal:filter",[68,135,137],{"x":136,"y":114,"style":115,"fill":66},"460","reads and rewrites every point",[68,139,140],{"x":136,"y":119,"style":115,"fill":66},"produces a real, smaller file",[68,142,143],{"x":136,"y":123,"style":115,"fill":83},"what you archive and hand on",[44,145],{"x":103,"y":146,"width":147,"height":148,"rx":149,"fill":150,"stroke":151,"style":152},"282","520","30","6","#fffdf7","#2563eb","stroke-width:2",[68,154,157],{"x":70,"y":155,"style":156,"fill":66,"textAnchor":74},"302","text-anchor:middle;font-size:10.5px;font-family:sans-serif","explore with the left one, then commit to the right one once",[159,160,162],"h2",{"id":161},"prerequisites","Prerequisites",[164,165,166,174,182],"ul",{},[167,168,169,173],"li",{},[170,171,172],"strong",{},"QGIS 3.34 LTR"," or newer with the PDAL provider.",[167,175,176,177,181],{},"An indexed cloud — see ",[21,178,180],{"href":179},"\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Fload-point-cloud-layer-pyqgis\u002F","loading a point cloud layer",".",[167,183,184],{},"Somewhere to write output, if you are producing a filtered file rather than a view.",[159,186,188],{"id":187},"check-what-is-actually-in-the-file-first","Check what is actually in the file first",[14,190,191],{},"Writing a filter before checking the classes is how you end up with an empty output and no idea why.",[193,194,199],"pre",{"className":195,"code":196,"language":197,"meta":198,"style":198},"language-python shiki shiki-themes github-dark","from qgis.core import QgsPointCloudLayer\n\ncloud = QgsPointCloudLayer(\"\u002Fdata\u002Findexed\u002Ftile_0345.copc.laz\", \"tile\", \"copc\")\nstats = cloud.statistics()\n\npresent = sorted(stats.classesOf(\"Classification\"))\nprint(\"classes present:\", present)\nprint(\"points:\", cloud.pointCount())\nprint(\"Z range:\", stats.minimum(\"Z\"), \"to\", stats.maximum(\"Z\"))\n","python","",[200,201,202,221,228,258,269,274,295,310,323],"code",{"__ignoreMap":198},[203,204,207,211,215,218],"span",{"class":205,"line":206},"line",1,[203,208,210],{"class":209},"snl16","from",[203,212,214],{"class":213},"s95oV"," qgis.core ",[203,216,217],{"class":209},"import",[203,219,220],{"class":213}," QgsPointCloudLayer\n",[203,222,224],{"class":205,"line":223},2,[203,225,227],{"emptyLinePlaceholder":226},true,"\n",[203,229,231,234,237,240,244,247,250,252,255],{"class":205,"line":230},3,[203,232,233],{"class":213},"cloud ",[203,235,236],{"class":209},"=",[203,238,239],{"class":213}," QgsPointCloudLayer(",[203,241,243],{"class":242},"sU2Wk","\"\u002Fdata\u002Findexed\u002Ftile_0345.copc.laz\"",[203,245,246],{"class":213},", ",[203,248,249],{"class":242},"\"tile\"",[203,251,246],{"class":213},[203,253,254],{"class":242},"\"copc\"",[203,256,257],{"class":213},")\n",[203,259,261,264,266],{"class":205,"line":260},4,[203,262,263],{"class":213},"stats ",[203,265,236],{"class":209},[203,267,268],{"class":213}," cloud.statistics()\n",[203,270,272],{"class":205,"line":271},5,[203,273,227],{"emptyLinePlaceholder":226},[203,275,277,280,282,286,289,292],{"class":205,"line":276},6,[203,278,279],{"class":213},"present ",[203,281,236],{"class":209},[203,283,285],{"class":284},"sDLfK"," sorted",[203,287,288],{"class":213},"(stats.classesOf(",[203,290,291],{"class":242},"\"Classification\"",[203,293,294],{"class":213},"))\n",[203,296,298,301,304,307],{"class":205,"line":297},7,[203,299,300],{"class":284},"print",[203,302,303],{"class":213},"(",[203,305,306],{"class":242},"\"classes present:\"",[203,308,309],{"class":213},", present)\n",[203,311,313,315,317,320],{"class":205,"line":312},8,[203,314,300],{"class":284},[203,316,303],{"class":213},[203,318,319],{"class":242},"\"points:\"",[203,321,322],{"class":213},", cloud.pointCount())\n",[203,324,326,328,330,333,336,339,342,345,348,350],{"class":205,"line":325},9,[203,327,300],{"class":284},[203,329,303],{"class":213},[203,331,332],{"class":242},"\"Z range:\"",[203,334,335],{"class":213},", stats.minimum(",[203,337,338],{"class":242},"\"Z\"",[203,340,341],{"class":213},"), ",[203,343,344],{"class":242},"\"to\"",[203,346,347],{"class":213},", stats.maximum(",[203,349,338],{"class":242},[203,351,294],{"class":213},[14,353,354,357,358,361,362,365,366,369,370,373],{},[170,355,356],{},"Breakdown:"," ",[200,359,360],{},"classesOf()"," returns the distinct values of a categorical attribute as they were found during indexing, so it is a cheap read rather than a scan. Three results are common and each means something different: ",[200,363,364],{},"[2, 5, 6, 9]"," is a properly classified survey; ",[200,367,368],{},"[1]"," alone means the delivery was never classified and no class filter will ever work; ",[200,371,372],{},"[1, 2]"," means somebody ran a ground filter and nothing else, which is enough for a terrain model and useless for anything about buildings. The Z range is a second sanity check — a range of thousands of metres over flat ground means noise points are present.",[159,375,377],{"id":376},"remove-noise-before-anything-else","Remove noise before anything else",[14,379,380],{},"Class 7 (low noise) and class 18 (high noise) exist because LiDAR sees birds, aircraft, cloud, multipath reflections and sensor artefacts. A single class-18 point at 4,000 m does not sound like much until it stretches the Z ramp of every rendering and pushes a surface model's maximum by four kilometres.",[193,382,384],{"className":195,"code":383,"language":197,"meta":198,"style":198},"import processing\n\nclean = processing.run(\"pdal:filter\", {\n    \"INPUT\": \"\u002Fdata\u002Findexed\u002Ftile_0345.copc.laz\",\n    \"FILTER_EXPRESSION\": \"Classification != 7 && Classification != 18\",\n    \"OUTPUT\": \"\u002Fdata\u002Fclean\u002Ftile_0345.copc.laz\",\n})[\"OUTPUT\"]\n",[200,385,386,393,397,413,426,438,450],{"__ignoreMap":198},[203,387,388,390],{"class":205,"line":206},[203,389,217],{"class":209},[203,391,392],{"class":213}," processing\n",[203,394,395],{"class":205,"line":223},[203,396,227],{"emptyLinePlaceholder":226},[203,398,399,402,404,407,410],{"class":205,"line":230},[203,400,401],{"class":213},"clean ",[203,403,236],{"class":209},[203,405,406],{"class":213}," processing.run(",[203,408,409],{"class":242},"\"pdal:filter\"",[203,411,412],{"class":213},", {\n",[203,414,415,418,421,423],{"class":205,"line":260},[203,416,417],{"class":242},"    \"INPUT\"",[203,419,420],{"class":213},": ",[203,422,243],{"class":242},[203,424,425],{"class":213},",\n",[203,427,428,431,433,436],{"class":205,"line":271},[203,429,430],{"class":242},"    \"FILTER_EXPRESSION\"",[203,432,420],{"class":213},[203,434,435],{"class":242},"\"Classification != 7 && Classification != 18\"",[203,437,425],{"class":213},[203,439,440,443,445,448],{"class":205,"line":276},[203,441,442],{"class":242},"    \"OUTPUT\"",[203,444,420],{"class":213},[203,446,447],{"class":242},"\"\u002Fdata\u002Fclean\u002Ftile_0345.copc.laz\"",[203,449,425],{"class":213},[203,451,452,455,458],{"class":205,"line":297},[203,453,454],{"class":213},"})[",[203,456,457],{"class":242},"\"OUTPUT\"",[203,459,460],{"class":213},"]\n",[14,462,463,465,466,469,470,473,474,469,477,480,481,484,485,488,489,181],{},[170,464,356],{}," PDAL expression syntax again — ",[200,467,468],{},"!="," and ",[200,471,472],{},"&&",", not ",[200,475,476],{},"\u003C>",[200,478,479],{},"AND",". Writing to a ",[200,482,483],{},".copc.laz"," extension keeps the output indexed, so the filtered file is immediately usable without another conversion step; writing to ",[200,486,487],{},".laz"," would give you an unindexed file and a re-indexing cost on every later open. If the survey has no noise classes at all, this step still costs a full read and write, so gate it on ",[200,490,491],{},"7 in present or 18 in present",[14,493,494],{},"Where noise was never classified, a statistical outlier filter is the alternative, and QGIS does not wrap one — that is a case for a PDAL pipeline run outside QGIS, or for clamping with a Z range you can defend from the survey's own metadata.",[159,496,498],{"id":497},"filter-to-the-returns-you-need","Filter to the returns you need",[14,500,501],{},[29,502,505,508,511,514,517,521,527,532,537,541,545,548,551,556,560,594,597,601,605,610,614,617,621,625,629],{"viewBox":503,"role":32,"ariaLabel":504,"xmlns":34},"0 0 760 336","Four common LiDAR filters shown as the classes they keep: terrain keeps ground only, surface keeps the highest return regardless of class, canopy keeps high vegetation, and buildings keep class six",[36,506,507],{},"Four filters, four different products",[40,509,510],{},"A terrain filter keeps class two. A surface filter keeps everything except noise and takes the maximum per cell. A canopy filter keeps classes three to five. A building filter keeps class six. Each one produces a completely different picture of the same place, and choosing wrongly is the most common cause of a plausible-looking but incorrect result.",[44,512],{"x":46,"y":46,"width":47,"height":513,"fill":49},"336",[68,515,516],{"x":70,"y":71,"style":72,"fill":73,"textAnchor":74},"The filter is the analysis",[44,518],{"x":519,"y":79,"width":109,"height":520,"rx":104,"fill":105,"stroke":106,"style":84},"20","150",[68,522,526],{"x":523,"y":524,"style":525,"fill":106,"textAnchor":74},"107","72","text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif","terrain",[63,528],{"d":529,"fill":93,"stroke":530,"style":531},"M36 160 Q76 148 108 154 T180 158","#92400e","stroke-width:3",[68,533,536],{"x":523,"y":534,"style":535,"fill":66,"textAnchor":74},"184","text-anchor:middle;font-size:10px;font-family:monospace","Classification == 2",[44,538],{"x":539,"y":79,"width":109,"height":520,"rx":104,"fill":540,"stroke":151,"style":84},"206","#eff3ff",[68,542,544],{"x":543,"y":524,"style":525,"fill":151,"textAnchor":74},"293","surface",[63,546],{"d":547,"fill":93,"stroke":151,"style":531},"M222 160 L246 160 L246 128 L282 128 L282 160 L310 160 Q330 132 348 142 L366 158",[68,549,550],{"x":543,"y":534,"style":535,"fill":66,"textAnchor":74},"Classification != 7",[44,552],{"x":553,"y":79,"width":109,"height":520,"rx":104,"fill":554,"stroke":555,"style":84},"392","#edf8e9","#15803d",[68,557,559],{"x":558,"y":524,"style":525,"fill":555,"textAnchor":74},"479","canopy",[561,562,563,568,571,575,579,583,587,590],"g",{"fill":555},[564,565],"circle",{"cx":566,"cy":520,"r":567},"424","3.4",[564,569],{"cx":127,"cy":570,"r":567},"130",[564,572],{"cx":573,"cy":574,"r":567},"456","144",[564,576],{"cx":577,"cy":578,"r":567},"472","118",[564,580],{"cx":581,"cy":582,"r":567},"490","136",[564,584],{"cx":585,"cy":586,"r":567},"508","126",[564,588],{"cx":589,"cy":101,"r":567},"526",[564,591],{"cx":592,"cy":593,"r":567},"544","132",[68,595,596],{"x":558,"y":534,"style":535,"fill":66,"textAnchor":74},"Classification >= 3 && \u003C= 5",[44,598],{"x":599,"y":79,"width":600,"height":520,"rx":104,"fill":128,"stroke":129,"style":84},"578","162",[68,602,604],{"x":603,"y":524,"style":525,"fill":129,"textAnchor":74},"659","buildings",[44,606],{"x":607,"y":103,"width":608,"height":100,"fill":609,"stroke":129,"style":152},"598","52","#c9d6cf",[44,611],{"x":612,"y":593,"width":79,"height":613,"fill":609,"stroke":129,"style":152},"668","28",[68,615,616],{"x":603,"y":534,"style":535,"fill":66,"textAnchor":74},"Classification == 6",[44,618],{"x":100,"y":119,"width":619,"height":620,"rx":104,"fill":150,"stroke":83,"style":152},"680","96",[68,622,624],{"x":70,"y":623,"style":88,"fill":73,"textAnchor":74},"248","the same tile, four ways — and only one of them answers your question",[68,626,628],{"x":70,"y":627,"style":156,"fill":66,"textAnchor":74},"274","flood modelling wants terrain; solar potential wants surface; forestry wants canopy minus terrain",[68,630,633],{"x":70,"y":631,"style":156,"fill":632,"textAnchor":74},"298","#b91c1c","a surface model used as a terrain model puts every roof into the catchment",[14,635,636],{},"For exploring, set a subset string and look at the canvas. It is instant and it undoes cleanly.",[193,638,640],{"className":195,"code":639,"language":197,"meta":198,"style":198},"cloud.setSubsetString(\"Classification IN (3, 4, 5)\")\ncloud.triggerRepaint()\n\nprint(cloud.subsetString())\ncloud.setSubsetString(\"\")\n",[200,641,642,652,657,661,668],{"__ignoreMap":198},[203,643,644,647,650],{"class":205,"line":206},[203,645,646],{"class":213},"cloud.setSubsetString(",[203,648,649],{"class":242},"\"Classification IN (3, 4, 5)\"",[203,651,257],{"class":213},[203,653,654],{"class":205,"line":223},[203,655,656],{"class":213},"cloud.triggerRepaint()\n",[203,658,659],{"class":205,"line":230},[203,660,227],{"emptyLinePlaceholder":226},[203,662,663,665],{"class":205,"line":260},[203,664,300],{"class":284},[203,666,667],{"class":213},"(cloud.subsetString())\n",[203,669,670,672,675],{"class":205,"line":271},[203,671,646],{"class":213},[203,673,674],{"class":242},"\"\"",[203,676,257],{"class":213},[14,678,679,681,682,685,686,246,688,690,691,694],{},[170,680,356],{}," QGIS expression syntax here — ",[200,683,684],{},"IN"," with a parenthesised list, single ",[200,687,236],{},[200,689,479],{},"\u002F",[200,692,693],{},"OR"," — because this is the layer's own filter and not PDAL's. It applies as the index is walked, so a heavily filtered view actually renders faster than the unfiltered one. It is not persisted in a project file for point cloud layers in every release, so treat it as a session-level tool rather than as configuration.",[14,696,697],{},"For committing, write the file:",[193,699,701],{"className":195,"code":700,"language":197,"meta":198,"style":198},"ground = processing.run(\"pdal:filter\", {\n    \"INPUT\": clean,\n    \"FILTER_EXPRESSION\": \"Classification == 2\",\n    \"OUTPUT\": \"\u002Fdata\u002Fclean\u002Ftile_0345_ground.copc.laz\",\n})[\"OUTPUT\"]\n",[200,702,703,716,723,734,745],{"__ignoreMap":198},[203,704,705,708,710,712,714],{"class":205,"line":206},[203,706,707],{"class":213},"ground ",[203,709,236],{"class":209},[203,711,406],{"class":213},[203,713,409],{"class":242},[203,715,412],{"class":213},[203,717,718,720],{"class":205,"line":223},[203,719,417],{"class":242},[203,721,722],{"class":213},": clean,\n",[203,724,725,727,729,732],{"class":205,"line":230},[203,726,430],{"class":242},[203,728,420],{"class":213},[203,730,731],{"class":242},"\"Classification == 2\"",[203,733,425],{"class":213},[203,735,736,738,740,743],{"class":205,"line":260},[203,737,442],{"class":242},[203,739,420],{"class":213},[203,741,742],{"class":242},"\"\u002Fdata\u002Fclean\u002Ftile_0345_ground.copc.laz\"",[203,744,425],{"class":213},[203,746,747,749,751],{"class":205,"line":271},[203,748,454],{"class":213},[203,750,457],{"class":242},[203,752,460],{"class":213},[14,754,755,357,757,759,760,763],{},[170,756,356],{},[200,758,133],{}," also accepts an ",[200,761,762],{},"EXTENT"," parameter, which crops in the same pass — worth using when you know you want both, because it halves the number of full reads. The output is a genuine point cloud file with a new header whose point count reflects the filter, which matters if anything downstream reads the count rather than the points.",[159,765,767],{"id":766},"filtering-on-return-number-and-intensity","Filtering on return number and intensity",[14,769,770],{},"Classification is not the only useful attribute, and on an unclassified delivery it is not even an available one. Two others carry real information.",[14,772,773,469,776,779,780,784],{},[200,774,775],{},"ReturnNumber",[200,777,778],{},"NumberOfReturns"," describe how a single laser pulse fragmented on its way down. A pulse that hits bare tarmac returns once: return 1 of 1. A pulse that clips a branch, then a lower branch, then the ground returns three times, and the ",[781,782,783],"em",{},"last"," of those is far more likely to be ground than the first. That gives a usable rough ground proxy with no classification at all.",[193,786,788],{"className":195,"code":787,"language":197,"meta":198,"style":198},"last_returns = processing.run(\"pdal:filter\", {\n    \"INPUT\": clean,\n    \"FILTER_EXPRESSION\": \"ReturnNumber == NumberOfReturns\",\n    \"OUTPUT\": \"\u002Fdata\u002Fclean\u002Ftile_0345_last.copc.laz\",\n})[\"OUTPUT\"]\n",[200,789,790,803,809,820,831],{"__ignoreMap":198},[203,791,792,795,797,799,801],{"class":205,"line":206},[203,793,794],{"class":213},"last_returns ",[203,796,236],{"class":209},[203,798,406],{"class":213},[203,800,409],{"class":242},[203,802,412],{"class":213},[203,804,805,807],{"class":205,"line":223},[203,806,417],{"class":242},[203,808,722],{"class":213},[203,810,811,813,815,818],{"class":205,"line":230},[203,812,430],{"class":242},[203,814,420],{"class":213},[203,816,817],{"class":242},"\"ReturnNumber == NumberOfReturns\"",[203,819,425],{"class":213},[203,821,822,824,826,829],{"class":205,"line":260},[203,823,442],{"class":242},[203,825,420],{"class":213},[203,827,828],{"class":242},"\"\u002Fdata\u002Fclean\u002Ftile_0345_last.copc.laz\"",[203,830,425],{"class":213},[203,832,833,835,837],{"class":205,"line":271},[203,834,454],{"class":213},[203,836,457],{"class":242},[203,838,460],{"class":213},[14,840,841,843,844,847],{},[170,842,356],{}," Comparing two attributes rather than an attribute and a constant is legal in PDAL expressions and is the whole trick here. The result is not a ground classification — a last return off a flat roof is still a roof — but over vegetated terrain it removes most of the canopy, and it is often enough to produce a usable first-pass surface while you chase the supplier for the classified delivery. Combining it with a classification test where classes do exist (",[200,845,846],{},"ReturnNumber == NumberOfReturns && Classification != 6",") tightens it considerably.",[14,849,850,853],{},[200,851,852],{},"Intensity"," records how much energy came back, and it separates materials that sit at the same height: water and wet asphalt return very little, dry sand and painted road markings return a lot. It is not calibrated between surveys, or reliably between flight lines within one survey, so a threshold that works on one tile may not work on the next. Read the range from the statistics before choosing one.",[193,855,857],{"className":195,"code":856,"language":197,"meta":198,"style":198},"low = stats.minimum(\"Intensity\")\nhigh = stats.maximum(\"Intensity\")\nprint(f\"intensity spans {low} to {high}\")\n\nbright = processing.run(\"pdal:filter\", {\n    \"INPUT\": clean,\n    \"FILTER_EXPRESSION\": f\"Intensity > {low + 0.8 * (high - low)}\",\n    \"OUTPUT\": \"TEMPORARY_OUTPUT\",\n})[\"OUTPUT\"]\n",[200,858,859,874,888,924,928,941,947,986,997],{"__ignoreMap":198},[203,860,861,864,866,869,872],{"class":205,"line":206},[203,862,863],{"class":213},"low ",[203,865,236],{"class":209},[203,867,868],{"class":213}," stats.minimum(",[203,870,871],{"class":242},"\"Intensity\"",[203,873,257],{"class":213},[203,875,876,879,881,884,886],{"class":205,"line":223},[203,877,878],{"class":213},"high ",[203,880,236],{"class":209},[203,882,883],{"class":213}," stats.maximum(",[203,885,871],{"class":242},[203,887,257],{"class":213},[203,889,890,892,894,897,900,903,906,909,912,914,917,919,922],{"class":205,"line":230},[203,891,300],{"class":284},[203,893,303],{"class":213},[203,895,896],{"class":209},"f",[203,898,899],{"class":242},"\"intensity spans ",[203,901,902],{"class":284},"{",[203,904,905],{"class":213},"low",[203,907,908],{"class":284},"}",[203,910,911],{"class":242}," to ",[203,913,902],{"class":284},[203,915,916],{"class":213},"high",[203,918,908],{"class":284},[203,920,921],{"class":242},"\"",[203,923,257],{"class":213},[203,925,926],{"class":205,"line":260},[203,927,227],{"emptyLinePlaceholder":226},[203,929,930,933,935,937,939],{"class":205,"line":271},[203,931,932],{"class":213},"bright ",[203,934,236],{"class":209},[203,936,406],{"class":213},[203,938,409],{"class":242},[203,940,412],{"class":213},[203,942,943,945],{"class":205,"line":276},[203,944,417],{"class":242},[203,946,722],{"class":213},[203,948,949,951,953,955,958,960,962,965,968,971,974,977,980,982,984],{"class":205,"line":297},[203,950,430],{"class":242},[203,952,420],{"class":213},[203,954,896],{"class":209},[203,956,957],{"class":242},"\"Intensity > ",[203,959,902],{"class":284},[203,961,863],{"class":213},[203,963,964],{"class":209},"+",[203,966,967],{"class":284}," 0.8",[203,969,970],{"class":209}," *",[203,972,973],{"class":213}," (high ",[203,975,976],{"class":209},"-",[203,978,979],{"class":213}," low)",[203,981,908],{"class":284},[203,983,921],{"class":242},[203,985,425],{"class":213},[203,987,988,990,992,995],{"class":205,"line":312},[203,989,442],{"class":242},[203,991,420],{"class":213},[203,993,994],{"class":242},"\"TEMPORARY_OUTPUT\"",[203,996,425],{"class":213},[203,998,999,1001,1003],{"class":205,"line":325},[203,1000,454],{"class":213},[203,1002,457],{"class":242},[203,1004,460],{"class":213},[14,1006,1007,1009,1010,1013],{},[170,1008,356],{}," Deriving the threshold from the file's own range rather than hard-coding a number is what makes this survive being run on a second tile. Note the f-string: the filter expression is just a string, so building it from computed values is normal and there is no parameter binding to worry about. Because intensity is uncalibrated, treat anything you derive from it as a candidate for review rather than as an answer — it is a good way to ",[781,1011,1012],{},"find"," the water bodies in a tile and a poor way to map them.",[159,1015,1017],{"id":1016},"when-the-survey-was-never-classified","When the survey was never classified",[14,1019,1020,1021,1024,1025,1027],{},"If ",[200,1022,1023],{},"classesOf(\"Classification\")"," returns ",[200,1026,368],{},", no class filter will help, and no amount of PyQGIS will retroactively classify the cloud — QGIS ships no ground-classification algorithm. There are three honest routes.",[14,1029,1030,1031,1034],{},"The first is to go back to the supplier: classification is normally part of the deliverable and an unclassified delivery is often simply a mistake in what was sent. The second is to run PDAL's own ground filter outside QGIS, through the ",[200,1032,1033],{},"pdal"," executable with a pipeline that applies an SMRF or PMF stage, and bring the result back as a normal file. The third, viable when you only need a surface model and not a terrain model, is to skip classification entirely and grid the maximum return per cell — no class filter required.",[14,1036,1037],{},[29,1038,1041,1044,1047,1050,1057,1060,1065,1069,1073,1076,1079,1084,1089,1094,1098,1102,1105,1108,1111,1114,1117,1120,1124,1127,1130,1133,1136],{"viewBox":1039,"role":32,"ariaLabel":1040,"xmlns":34},"0 0 760 306","A decision path for an unclassified point cloud: return it to the supplier, classify it outside QGIS with a PDAL ground filter, or accept a surface model that needs no classification",[36,1042,1043],{},"What to do with an unclassified delivery",[40,1045,1046],{},"When every point is class one, three routes remain. Ask the supplier for the classified delivery. Run a ground classification stage in PDAL outside QGIS and bring the result back. Or accept that a surface model, built from the highest return per cell, needs no classification at all.",[44,1048],{"x":46,"y":46,"width":47,"height":1049,"fill":49},"306",[51,1051,1052],{},[54,1053,1055],{"id":1054,"viewBox":57,"refX":58,"refY":59,"markerWidth":60,"markerHeight":60,"orient":61},"fcpUncArrow",[63,1056],{"d":65,"fill":66},[68,1058,1059],{"x":70,"y":71,"style":72,"fill":73,"textAnchor":74},"classesOf(\"Classification\") returned [1]",[44,1061],{"x":1062,"y":1063,"width":1064,"height":79,"rx":58,"fill":150,"stroke":632,"style":84},"272","44","216",[68,1066,1068],{"x":70,"y":1067,"style":525,"fill":632,"textAnchor":74},"73","nothing is classified",[63,1070],{"d":1071,"fill":93,"stroke":66,"style":1072},"M380 90 L380 112 L128 112 L128 138","stroke-width:2;marker-end:url(#fcpUncArrow)",[63,1074],{"d":1075,"fill":93,"stroke":66,"style":1072},"M380 90 L380 138",[63,1077],{"d":1078,"fill":93,"stroke":66,"style":1072},"M380 90 L380 112 L632 112 L632 138",[44,1080],{"x":1081,"y":574,"width":1082,"height":1083,"rx":104,"fill":540,"stroke":151,"style":84},"24","208","104",[68,1085,1088],{"x":1086,"y":1087,"style":525,"fill":151,"textAnchor":74},"128","170","ask the supplier",[68,1090,1093],{"x":1086,"y":1091,"style":1092,"fill":66,"textAnchor":74},"194","text-anchor:middle;font-size:10px;font-family:sans-serif","classification is usually",[68,1095,1097],{"x":1086,"y":1096,"style":1092,"fill":66,"textAnchor":74},"212","part of the contract",[68,1099,1101],{"x":1086,"y":1100,"style":1092,"fill":83,"textAnchor":74},"234","cheapest by far",[44,1103],{"x":1104,"y":574,"width":1082,"height":1083,"rx":104,"fill":128,"stroke":129,"style":84},"276",[68,1106,1107],{"x":70,"y":1087,"style":525,"fill":129,"textAnchor":74},"classify outside QGIS",[68,1109,1110],{"x":70,"y":1091,"style":1092,"fill":66,"textAnchor":74},"a PDAL pipeline with an",[68,1112,1113],{"x":70,"y":1096,"style":1092,"fill":66,"textAnchor":74},"SMRF or PMF ground stage",[68,1115,1116],{"x":70,"y":1100,"style":1092,"fill":83,"textAnchor":74},"needs tuning per landscape",[44,1118],{"x":1119,"y":574,"width":1082,"height":1083,"rx":104,"fill":554,"stroke":555,"style":84},"528",[68,1121,1123],{"x":1122,"y":1087,"style":525,"fill":555,"textAnchor":74},"632","build a surface model",[68,1125,1126],{"x":1122,"y":1091,"style":1092,"fill":66,"textAnchor":74},"maximum return per cell,",[68,1128,1129],{"x":1122,"y":1096,"style":1092,"fill":66,"textAnchor":74},"no classes needed",[68,1131,1132],{"x":1122,"y":1100,"style":1092,"fill":83,"textAnchor":74},"works today, answers less",[44,1134],{"x":103,"y":1135,"width":147,"height":148,"rx":149,"fill":150,"stroke":83,"style":152},"266",[68,1137,1139],{"x":70,"y":1138,"style":156,"fill":66,"textAnchor":74},"286","what does not work: pretending class 1 is class 2",[159,1141,1143],{"id":1142},"qgis-version-compatibility","QGIS version compatibility",[14,1145,1146,1147,1149,1150,1152,1153,1155],{},"Subset strings on point cloud layers require 3.26 or newer; ",[200,1148,133],{}," requires 3.32. Both are present in 3.34 LTR and unchanged through 3.44. The ",[200,1151,762],{}," parameter on ",[200,1154,133],{}," was added alongside the algorithm and takes a rectangle string in the layer's CRS, not a layer — passing a layer there fails validation rather than silently ignoring it.",[159,1157,1159],{"id":1158},"troubleshooting","Troubleshooting",[164,1161,1162,1177,1190,1204,1213,1222],{},[167,1163,1164,1167,1168,1170,1171,1173,1174,181],{},[170,1165,1166],{},"The filtered output has zero points."," The expression matched nothing. Print ",[200,1169,1023],{}," and check for ",[200,1172,236],{}," where PDAL wants ",[200,1175,1176],{},"==",[167,1178,1179,1182,1183,1186,1187,181],{},[170,1180,1181],{},"The subset string had no effect on an exported raster."," Expected — ",[200,1184,1185],{},"pdal:"," algorithms read the file and ignore the layer's view. Repeat the condition in ",[200,1188,1189],{},"FILTER_EXPRESSION",[167,1191,1192,357,1198,1200,1201,181],{},[170,1193,1194,1197],{},[200,1195,1196],{},"Classification IN (2,9)"," is rejected by the algorithm.",[200,1199,684],{}," is QGIS syntax. PDAL wants ",[200,1202,1203],{},"Classification == 2 || Classification == 9",[167,1205,1206,1209,1210,1212],{},[170,1207,1208],{},"The output file is bigger than the input."," The output was written uncompressed, or to a different point format. Keep the ",[200,1211,483],{}," extension.",[167,1214,1215,1218,1219,1221],{},[170,1216,1217],{},"Filtering is slower than expected on a virtual point cloud."," Every member tile is read. Add an ",[200,1220,762],{}," so tiles outside it are skipped entirely.",[167,1223,1224,1227],{},[170,1225,1226],{},"Class 2 exists but the terrain model still has roofs in it."," The survey's ground classification is poor, which happens on dense urban blocks. Compare against the surface model before trusting it.",[159,1229,1231],{"id":1230},"conclusion","Conclusion",[14,1233,1234,1235,1237],{},"Read the classes before writing the filter, drop noise in its own pass, explore with a subset string and commit with ",[200,1236,133],{},", and remember which expression language you are in. When a delivery is unclassified, say so out loud rather than working around it — the workarounds all quietly change what the output means.",[159,1239,1241],{"id":1240},"frequently-asked-questions","Frequently Asked Questions",[14,1243,1244,1247],{},[170,1245,1246],{},"Can I edit the classification values in place?","\nNo. Point cloud layers are read-only in QGIS, and there is no wrapped algorithm that assigns classifications. Producing a reclassified cloud means writing a new file from a PDAL pipeline run outside QGIS.",[14,1249,1250,1253],{},[170,1251,1252],{},"Does filtering speed up rendering?","\nA subset string does, because fewer points are read from the index. A written filtered file does too, and additionally makes every subsequent algorithm faster, which is why it is worth doing once for a cloud you will use repeatedly.",[14,1255,1256,1259,1260,246,1262,246,1264,246,1266,246,1269,1272,1273,1276],{},[170,1257,1258],{},"How do I filter by something other than classification?","\nAny attribute in the file works in both languages — ",[200,1261,852],{},[200,1263,775],{},[200,1265,778],{},[200,1267,1268],{},"ScanAngleRank",[200,1270,1271],{},"GpsTime",". Filtering ",[200,1274,1275],{},"ReturnNumber == NumberOfReturns"," keeps last returns, which is a useful rough proxy for ground when nothing is classified.",[14,1278,1279,1282],{},[170,1280,1281],{},"Is there a way to preview the effect before writing a file?","\nYes, and it is the whole reason subset strings are worth knowing: set the equivalent QGIS expression, look at the canvas, then translate it to PDAL syntax once you are happy.",[159,1284,1286],{"id":1285},"related","Related",[164,1288,1289,1294,1300,1306,1312],{},[167,1290,1291,1293],{},[21,1292,24],{"href":23}," — the guide this recipe belongs to",[167,1295,1296],{},[21,1297,1299],{"href":1298},"\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Frun-pdal-algorithms-from-pyqgis\u002F","Run the PDAL Algorithms from PyQGIS",[167,1301,1302],{},[21,1303,1305],{"href":1304},"\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Fcreate-dem-from-point-cloud-pyqgis\u002F","Create a DEM from a Point Cloud in PyQGIS",[167,1307,1308],{},[21,1309,1311],{"href":1310},"\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Fstyle-point-cloud-renderer-pyqgis\u002F","Style a Point Cloud Renderer in PyQGIS",[167,1313,1314],{},[21,1315,1317],{"href":1316},"\u002Fpyqgis-fundamentals-environment-setup\u002Fworking-with-qgis-expressions\u002Fselect-features-by-expression-pyqgis\u002F","Select Features by Expression in PyQGIS",[1319,1320,1321],"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":198,"searchDepth":223,"depth":223,"links":1323},[1324,1325,1326,1327,1328,1329,1330,1331,1332,1333,1334],{"id":161,"depth":223,"text":162},{"id":187,"depth":223,"text":188},{"id":376,"depth":223,"text":377},{"id":497,"depth":223,"text":498},{"id":766,"depth":223,"text":767},{"id":1016,"depth":223,"text":1017},{"id":1142,"depth":223,"text":1143},{"id":1158,"depth":223,"text":1159},{"id":1230,"depth":223,"text":1231},{"id":1240,"depth":223,"text":1241},{"id":1285,"depth":223,"text":1286},"Cut a LiDAR cloud down to the returns you need — subset strings for a live view, pdal:filter for a written file, noise removal, and what to do when the survey was never classified.","md",{"slug":1338,"type":1339,"breadcrumb":1340,"datePublished":1341,"dateModified":1341},"filter-and-classify-point-cloud-pyqgis","article","Filter & Classify a Point Cloud","2026-09-04","\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Ffilter-and-classify-point-cloud-pyqgis",{"title":5,"description":1335},"spatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Ffilter-and-classify-point-cloud-pyqgis\u002Findex","Iy3z_vmMSk9AO6HhvkwdVD_FWo_N1ietvu1cqB8blPE",1788563856112]