Correspondence-driven plane-based M3C2 (PBM3C2) with pre-segmented planes

Implemented in: py4dgeo.pbm3c2

Implemented by
Xiaoyu Huang (@xyuhuang, Technical University of Munich)

Author(s) of the method Vivien Zahs, Lukas Winiwarter, Bernhard Höfle (Heidelberg University)

Original publication of the method Zahs, V., Winiwarter, L., Anders, K., Williams, J.G., Rutzinger, M. & Höfle, B. (2022): Correspondence-driven plane-based M3C2 for lower uncertainty in 3D topographic change quantification. ISPRS Journal of Photogrammetry and Remote Sensing, 183, pp. 541-559. DOI: 10.1016/j.isprsjprs.2021.11.018.

Method description

In this notebook, we present how the Correspondence-driven plane-based M3C2 (PB-M3C2, [Zahs et al., 2022] algorithm for point cloud distance computation using the py4dgeo package.

The concept and method of PBM3C2 are explained in this scientific talk:

In the current implementation of PBM3C2, a plane segmentation outside py4dgeo (e.g., using CloudCompare or other tools) is required. As PB-M3C2 is a learning algorithm, it requires user-labelled input data in the process, which can be created in graphical software, such as CloudCompare.

[1]:
import py4dgeo

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

from py4dgeo.util import find_file
Numba is not installed.
Use `pip install numba` to install it to enable faster computations in Vapc.
You can run py4dgeo.Vapc without Numba, but it may be slower.

In this notebook, we use a dataset of synthetic planes, which is downloaded from the py4dgeo data repository:

[2]:
# Fetch original test data
test_filename = "pbm3c2.zip"
original_file = find_file(test_filename)
print(f"File downloaded to: {original_file}")
File downloaded to: /home/docs/.cache/py4dgeo/pbm3c2.zip

We are reading the two input epochs from XYZ files which contain a total of four columns: X, Y and Z coordinates, as well a segment ID mapping each point to a plane and normal vector components in X, Y and Z. The read_from_xyz functionality allows us to read additional data columns through its additional_dimensions parameter. It is expecting a dictionary that maps the column index to a column name.

[3]:
epoch0 = py4dgeo.epoch.read_from_xyz(
    find_file("epoch0.xyz"),
    additional_dimensions={3: "segment_id", 4: "N_x", 5: "N_y", 6: "N_z"},
    delimiter=" ",
)
epoch1 = py4dgeo.epoch.read_from_xyz(
    find_file("epoch1.xyz"),
    additional_dimensions={3: "segment_id", 4: "N_x", 5: "N_y", 6: "N_z"},
    delimiter=" ",
)
[2026-02-26 08:10:59][INFO] Reading point cloud from file '/home/docs/.cache/py4dgeo/extracted/pbm3c2/epoch0.xyz'
[2026-02-26 08:10:59][INFO] Reading point cloud from file '/home/docs/.cache/py4dgeo/extracted/pbm3c2/epoch1.xyz'

The point cloud data we use here consists of 100 planar segments, with 70 used for training and 30 for application.

[4]:
n_planes = 100
n_train = int(0.7 * n_planes)
train_ids = np.arange(n_train)
apply_ids = np.arange(n_train, n_planes)

We instantiate an instance of the algorithm class. Here, you can set the registration error for the input point clouds.

[5]:
alg = py4dgeo.PBM3C2(registration_error=0.01)

The algorithm requires the user to provide a labeled training dataset correspondences_file to learn how to match the segments. This csv file contains three columns: the first two are the plane segment_id from epoch 1 and epoch 2, and the third is a label (1 for a correct match, 0 for an incorrect one).

Here is an example of the correspondences_file structure:

[6]:
training_sample = pd.read_csv(find_file("epoch_extended_y.csv"), header=None, nrows=3)

print("Training correspondence file structure (first 3 rows):")
print(training_sample.to_string(index=False, header=False))
print("\nFormat explanation:")
print("  - Column 0: Segment ID from epoch 0")
print("  - Column 1: Corresponding segment ID from epoch 1")
print("  - Column 2: Label (1 = correct match, 0 = incorrect match)")
Training correspondence file structure (first 3 rows):
0 100 1
1 101 1
2 102 1

Format explanation:
  - Column 0: Segment ID from epoch 0
  - Column 1: Corresponding segment ID from epoch 1
  - Column 2: Label (1 = correct match, 0 = incorrect match)
[7]:
correspondences_df = alg.run(
    epoch0=epoch0,
    epoch1=epoch1,
    correspondences_file=find_file("epoch_extended_y.csv"),
    apply_ids=apply_ids,
    search_radius=5.0,
)
============================================================
PBM3C2 Processing Pipeline
============================================================

[1/6] Preprocessing epochs and correspondences...
Assigning globally unique segment IDs...
  Epoch 0: 100 unique segments (range: 0.0-99.0)
  Epoch 1: 100 unique segments (range: 100.0-199.0)
  Assigned new IDs for Epoch 0: 1 to 100
  Assigned new IDs for Epoch 1: 101 to 200
  Remapped 139 correspondence pairs to new ID scheme
Preprocessing complete.

[2/6] Loading and processing segments...
  Loaded 100 segments from epoch 0, 100 from epoch 1

[3/6] Extracting geometric features...
  Computed metrics for 100 + 100 segments

[4/6] Training Random Forest classifier...
  Classifier trained on 139 pairs

[5/6] Finding correspondences...

[6/6] Calculating M3C2 distances and uncertainties...

[Final] Mapping results back to original segment IDs...
============================================================
Processing complete! Found 30 matches
============================================================
[8]:
print(correspondences_df.head())
   epoch0_original_id  epoch1_original_id  epoch0_segment_id  \
0                70.0               170.0                 71
1                71.0               171.0                 72
2                72.0               172.0                 73
3                73.0               173.0                 74
4                74.0               174.0                 75

   epoch1_segment_id  distance  uncertainty
0              171.0  0.091296     0.011290
1              172.0  0.132205     0.011429
2              173.0  0.300687     0.011440
3              174.0  0.369171     0.011403
4              175.0 -0.294675     0.011339
[9]:
distances = correspondences_df["distance"]
uncertainties = correspondences_df["uncertainty"]

We can visualize the matched plane correspondences and their spatial relationships:

The visualize_correspondences function includes parameters to control its plotting behavior, offering the following options: pinpoint a single epoch0_segment_id for detailed zoom-in display; enable the show_all=True option to render all content; or directly use the default random num_samples value for a quick preview.

[10]:
fig, ax = alg.visualize_correspondences(epoch0_segment_id=70, elev=20, azim=135)
plt.show()
_images/pbm3c2_18_0.png

References

  • Zahs, V., Winiwarter, L., Anders, K., Williams, J.G., Rutzinger, M. & Höfle, B. (2022): Correspondence-driven plane-based M3C2 for lower uncertainty in 3D topographic change quantification. ISPRS Journal of Photogrammetry and Remote Sensing, 183, pp. 541-559. DOI: 10.1016/j.isprsjprs.2021.11.018.