Skip to content

Commit 9aee368

Browse files
authored
Compare alg - using M3C2 (#83)
* tool to compare point clouds * only add the tool if pdal version 2.10 or higher * download two datasets for testing compare tool * test for compare * add skip condition to avoid failing tests * fix * remove debug leftover * compare should never support streaming * add step-sample instead of sample-pct * update pipeline * use build directory * values changed due to sampling change * update year * format * remove * update parameters * can be empty, with default value 0.0 * rename parameter * if subsamplingCellSize == 0 skip sampling * do not store writer * update test * add another test without subsampling-cell-size set * update data path * update readme
1 parent 2aa8fe5 commit 9aee368

File tree

8 files changed

+362
-11
lines changed

8 files changed

+362
-11
lines changed

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ add_executable(pdal_wrench
3030
src/utils.cpp
3131
src/vpc.cpp
3232
src/height_above_ground.cpp
33+
src/compare.cpp
3334

3435
src/tile/tile.cpp
3536
src/tile/BufferCache.cpp

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,15 @@ pdal_wrench height_above_ground --input=data.las --output=data_hag.las --algorit
218218
pdal_wrench height_above_ground --input=data.las --output=data_hag.las --algorithm=delaunay --replace-z=true --delaunay-count=10
219219
```
220220

221+
## compare
222+
223+
Compares two point clouds using M3C2 algorithm and outputs a point cloud with new dimensions: m3c2_distance, m3c2_uncertainty, m3c2_significant, m3c2_std_dev1, m3c2_std_dev2, m3c2_count1 and m3c2_count2. The input data is subsampled to create set of core points, subsampling can be modified using subsampling-cell-size parameter, if it is set to 0.0, no subsampling is done and all points are used.
224+
225+
```
226+
pdal_wrench compare --input=first.las --input-compare=second.las --output=changes.las --subsampling-cell-size=1.0 --normal-radius=2.0 --cyl-radius=2.0 --cyl-halflen=5.0 --reg-error=0.0 --cyl-orientation=up
227+
```
228+
229+
221230
# Virtual Point Clouds (VPC)
222231

223232
This is similar to GDAL's VRT - a single file referring to other files that contain actual data. Software then may handle all data as a single dataset.
@@ -271,3 +280,4 @@ When algorithms create derived VPCs, by default they use uncompressed LAS, but `
271280
| height_above_ground | multi-threaded | per file |
272281
| filter_noise | multi-threaded | per file |
273282
| classify_ground | multi-threaded | per file |
283+
| compare | no | only available in PDAL Version > 2.10 |

src/alg.hpp

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,34 @@ struct HeightAboveGround : public Alg
441441

442442
std::vector<std::string> tileOutputFiles;
443443

444+
// impl
445+
virtual void addArgs() override;
446+
virtual bool checkArgs() override;
447+
virtual void preparePipelines(std::vector<std::unique_ptr<PipelineManager>>& pipelines) override;
448+
virtual void finalize(std::vector<std::unique_ptr<PipelineManager>>& pipelines) override;
449+
};
450+
451+
struct ComparePointClouds : public Alg
452+
{
453+
ComparePointClouds() { isStreaming = false; }
454+
455+
// parameters from the user
456+
std::string outputFile;
457+
458+
std::string comparedInputFile;
459+
double subsamplingCellSize; // cell size for Poisson sampling
460+
double normalRadius = 2.0;
461+
double cylRadius = 2.0;
462+
double cylHalflen = 5.0;
463+
double regError = 0.0;
464+
std::string cylOrientation = "up";
465+
466+
// args - initialized in addArgs()
467+
pdal::Arg* argOutput = nullptr;
468+
pdal::Arg* argOutputFormat = nullptr;
469+
pdal::Arg* argComparedInputFile = nullptr;
470+
pdal::Arg* argOrientation = nullptr;
471+
444472
// impl
445473
virtual void addArgs() override;
446474
virtual bool checkArgs() override;

src/compare.cpp

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/*****************************************************************************
2+
* Copyright (c) 2026, Lutra Consulting Ltd. and Hobu, Inc. *
3+
* *
4+
* All rights reserved. *
5+
* *
6+
* This program is free software; you can redistribute it and/or modify *
7+
* it under the terms of the GNU General Public License as published by *
8+
* the Free Software Foundation; either version 3 of the License, or *
9+
* (at your option) any later version. *
10+
* *
11+
****************************************************************************/
12+
13+
#include <filesystem>
14+
#include <iostream>
15+
#include <thread>
16+
17+
#include <pdal/PipelineManager.hpp>
18+
#include <pdal/Polygon.hpp>
19+
#include <pdal/Stage.hpp>
20+
#include <pdal/pdal_types.hpp>
21+
#include <pdal/util/ProgramArgs.hpp>
22+
23+
#include <gdal_utils.h>
24+
25+
#include "alg.hpp"
26+
#include "utils.hpp"
27+
#include "vpc.hpp"
28+
29+
using namespace pdal;
30+
31+
void ComparePointClouds::addArgs()
32+
{
33+
argOutput = &programArgs.add("output,o", "Output point cloud file", outputFile);
34+
argComparedInputFile = &programArgs.add("input-compare", "Point cloud file to compare against input", comparedInputFile);
35+
36+
programArgs.add("subsampling-cell-size", "Minimum spacing between points", subsamplingCellSize, 0.0);
37+
38+
programArgs.add( "normal-radius", "Radius of the sphere around each core point that defines the neighbors from which normals are calculated.", normalRadius, 2.0);
39+
programArgs.add("cyl-radius", "Radius of the cylinder inside of which points are searched for when calculating change", cylRadius, 2.0);
40+
programArgs.add("cyl-halflen", "The half-length of the cylinder of neighbors used for calculating change", cylHalflen, 5.0);
41+
programArgs.add("reg-error", "Registration error", regError, 0.0);
42+
argOrientation = &programArgs.add( "cyl-orientation", "Which direction to orient the cylinder/normal vector used for comparison between the two point clouds. (up, origin, none)", cylOrientation, "up");
43+
}
44+
45+
bool ComparePointClouds::checkArgs()
46+
{
47+
48+
if (ends_with(inputFile, ".vpc")) {
49+
std::cerr << "input cannot be a VPC file" << std::endl;
50+
return false;
51+
}
52+
53+
if (ends_with(comparedInputFile, ".vpc")) {
54+
std::cerr << "compared input cannot be a VPC file" << std::endl;
55+
return false;
56+
}
57+
58+
if (!argOutput->set()) {
59+
std::cerr << "missing output" << std::endl;
60+
return false;
61+
}
62+
63+
if (!argComparedInputFile->set()) {
64+
std::cerr << "missing compared input file" << std::endl;
65+
return false;
66+
}
67+
68+
if (argOrientation->set()) {
69+
if (cylOrientation != "up" && cylOrientation != "origin" &&
70+
cylOrientation != "none") {
71+
std::cerr << "unknown orientation: " << cylOrientation << std::endl;
72+
return false;
73+
}
74+
}
75+
76+
return true;
77+
}
78+
79+
static std::unique_ptr<PipelineManager> pipeline(ParallelJobInfo *tile, std::string compareFile, double subsamplingCellSize, double normalRadius, double cylRadius, double cylHalflen, double regError, std::string cylOrientation)
80+
{
81+
std::unique_ptr<PipelineManager> manager(new PipelineManager);
82+
83+
Stage &reader1 = makeReader(manager.get(), tile->inputFilenames[0]);
84+
Stage *last = &reader1;
85+
86+
// filtering
87+
if (!tile->filterBounds.empty())
88+
{
89+
Options filter_opts;
90+
filter_opts.add(pdal::Option("bounds", tile->filterBounds));
91+
92+
if (readerSupportsBounds(reader1))
93+
{
94+
// Reader of the format can do the filtering - use that whenever possible!
95+
reader1.addOptions(filter_opts);
96+
}
97+
else
98+
{
99+
// Reader can't do the filtering - do it with a filter
100+
last = &manager->makeFilter("filters.crop", *last, filter_opts);
101+
}
102+
}
103+
104+
if (!tile->filterExpression.empty())
105+
{
106+
Options filter_opts;
107+
filter_opts.add(pdal::Option("expression", tile->filterExpression));
108+
last = &manager->makeFilter("filters.expression", *last, filter_opts);
109+
}
110+
111+
Stage *reader1FilteredCropped = last;
112+
113+
Stage &reader2 = makeReader(manager.get(), compareFile);
114+
last = &reader2;
115+
116+
// filtering
117+
if (!tile->filterBounds.empty())
118+
{
119+
Options filter_opts;
120+
filter_opts.add(pdal::Option("bounds", tile->filterBounds));
121+
122+
if (readerSupportsBounds(reader2))
123+
{
124+
// Reader of the format can do the filtering - use that whenever possible!
125+
reader2.addOptions(filter_opts);
126+
}
127+
else
128+
{
129+
// Reader can't do the filtering - do it with a filter
130+
last = &manager->makeFilter("filters.crop", *last, filter_opts);
131+
}
132+
}
133+
134+
if (!tile->filterExpression.empty())
135+
{
136+
Options filter_opts;
137+
filter_opts.add(pdal::Option("expression", tile->filterExpression));
138+
last = &manager->makeFilter("filters.expression", *last, filter_opts);
139+
}
140+
Stage *reader2FilteredCropped = last;
141+
142+
Stage *corePoints = nullptr;
143+
if (subsamplingCellSize != 0.0)
144+
{
145+
pdal::Options sample_opts;
146+
sample_opts.add(pdal::Option("cell", subsamplingCellSize));
147+
corePoints = &manager->makeFilter("filters.sample", *reader1FilteredCropped, sample_opts);
148+
}
149+
else
150+
{
151+
corePoints = reader1FilteredCropped;
152+
}
153+
154+
Options compare_opts;
155+
compare_opts.add(pdal::Option("normal_radius", normalRadius));
156+
compare_opts.add(pdal::Option("cyl_radius", cylRadius));
157+
compare_opts.add(pdal::Option("cyl_halflen", cylHalflen));
158+
compare_opts.add(pdal::Option("reg_error", regError));
159+
compare_opts.add(pdal::Option("orientation", cylOrientation));
160+
161+
Stage *filterM3c2 = &manager->makeFilter("filters.m3c2", compare_opts);
162+
163+
// inputs to M3C2 filter are origin file, compared file and core points (in order)
164+
filterM3c2->setInput(*reader1FilteredCropped);
165+
filterM3c2->setInput(*reader2FilteredCropped);
166+
filterM3c2->setInput(*corePoints);
167+
168+
makeWriter(manager.get(), tile->outputFilename, filterM3c2);
169+
170+
return manager;
171+
}
172+
173+
void ComparePointClouds::preparePipelines(std::vector<std::unique_ptr<PipelineManager>> &pipelines)
174+
{
175+
ParallelJobInfo tile(ParallelJobInfo::Single, BOX2D(), filterExpression,
176+
filterBounds);
177+
tile.inputFilenames.push_back(inputFile);
178+
tile.outputFilename = outputFile;
179+
pipelines.push_back(pipeline(&tile, comparedInputFile, subsamplingCellSize, normalRadius, cylRadius, cylHalflen, regError, cylOrientation));
180+
}
181+
182+
void ComparePointClouds::finalize( std::vector<std::unique_ptr<PipelineManager>> &)
183+
{
184+
}

src/main.cpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
#include <pdal/util/FileUtils.hpp>
2323
#include <pdal/pdal_config.hpp>
24+
#include <pdal/pdal_features.hpp>
2425

2526
#include "gdal_version.h"
2627

@@ -42,6 +43,10 @@ void printUsage()
4243
std::cout << " build_vpc Creates a virtual point cloud" << std::endl;
4344
std::cout << " classify_ground Classify ground points" << std::endl;
4445
std::cout << " clip Outputs only points that are inside of the clipping polygons" << std::endl;
46+
// TODO this check can be removed once PDAL 2.10 is widely used
47+
#if PDAL_VERSION_MAJOR > 2 || ( PDAL_VERSION_MAJOR == 2 && PDAL_VERSION_MINOR >= 10 )
48+
std::cout << " compare Compares two point clouds for differences" << std::endl;
49+
#endif
4550
std::cout << " density Exports a raster where each cell contains number of points" << std::endl;
4651
std::cout << " filter_noise Classify noise points" << std::endl;
4752
std::cout << " height_above_ground Calculates height above ground for each point" << std::endl;
@@ -154,6 +159,14 @@ int main(int argc, char* argv[])
154159
ClassifyGround classifyGround;
155160
runAlg(args, classifyGround);
156161
}
162+
// TODO this check can be removed once PDAL 2.10 is is widely used
163+
#if PDAL_VERSION_MAJOR > 2 || ( PDAL_VERSION_MAJOR == 2 && PDAL_VERSION_MINOR >= 10 )
164+
else if (cmd == "compare")
165+
{
166+
ComparePointClouds comparison;
167+
runAlg(args, comparison);
168+
}
169+
#endif
157170
else
158171
{
159172
std::cerr << "unknown command: " << cmd << std::endl;

tests/conftest.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,25 @@ def _prepare_data():
1717

1818
base_data = utils.test_data_filepath("stadium-utm.laz")
1919

20-
if not base_data.exists():
21-
# PDAL autzen stadium dataset
22-
url = "https://media.githubusercontent.com/media/PDAL/data/refs/heads/main/autzen/stadium-utm.laz"
23-
24-
r = requests.get(url, timeout=10 * 60)
25-
26-
with open(base_data, "wb") as f:
27-
f.write(r.content)
28-
# Run the pdal_wrench command
20+
# PDAL autzen stadium dataset
21+
utils.download_data(
22+
"https://media.githubusercontent.com/media/PDAL/data/refs/heads/main/autzen/stadium-utm.laz", base_data
23+
)
24+
25+
autzen_2010_data = utils.test_data_filepath("autzen-bmx-2010.las")
26+
autzen_2023_data = utils.test_data_filepath("autzen-bmx-2023.las")
27+
28+
utils.download_data(
29+
"https://raw.githubusercontent.com/PDAL/PDAL/a057739ef0c620d4668752becef25f2b196bf174/test/data/autzen/autzen-bmx-2010.las",
30+
autzen_2010_data,
31+
)
32+
utils.download_data(
33+
"https://raw.githubusercontent.com/PDAL/PDAL/a057739ef0c620d4668752becef25f2b196bf174/test/data/autzen/autzen-bmx-2023.las",
34+
autzen_2023_data,
35+
)
36+
37+
pdal.Reader(autzen_2010_data.as_posix()).pipeline().execute()
38+
pdal.Reader(autzen_2023_data.as_posix()).pipeline().execute()
2939

3040
laz_file = pdal.Reader(base_data.as_posix()).pipeline()
3141
number_points = laz_file.execute()
@@ -217,3 +227,15 @@ def main_copc_file() -> str:
217227
def main_copc_file_without_classification() -> str:
218228
"Return path to the main copc file without classification"
219229
return utils.test_data_filepath("stadium-utm-not-classified.copc.laz").as_posix()
230+
231+
232+
@pytest.fixture
233+
def autzen_2010_file() -> str:
234+
"Return path to the autzen 2010 las file"
235+
return utils.test_data_filepath("autzen-bmx-2010.las").as_posix()
236+
237+
238+
@pytest.fixture
239+
def autzen_2023_file() -> str:
240+
"Return path to the autzen 2023 las file"
241+
return utils.test_data_filepath("autzen-bmx-2023.las").as_posix()

0 commit comments

Comments
 (0)