Detection of tree stems with Pyoints

In the following, we try to detect stems in a forest using a three dimensional point cloud generated by a terrestrial laser scanner. The basic steps are:

1. Loading of the point cloud data
2. Calculation of the height above ground
3. Filtering of stem points
4. Identification of stem clusters
5. Fitting of stem vectors

We begin with loading the required modules.

[1]:
import numpy as np

from pyoints import (
    storage,
    Extent,
    filters,
    interpolate,
    clustering,
    classification,
    vector,
)
[2]:
from mpl_toolkits import mplot3d
import matplotlib.pyplot as plt

%matplotlib inline

Loading of the point cloud data

We load a three dimensional point cloud scanned in a forest.

[3]:
lasReader = storage.LasReader('forest.las')
las = lasReader.load()
print(len(las))
482981

We receive a LasRecords instance representing the point cloud. We can treat it like a numpy record array with additional features. So, we inspect its properties first.

[4]:
print('shape:')
print(las.shape)
print('attributes:')
print(las.dtype.descr)
print('projection:')
print(las.proj.proj4)
print('transformation matrix:')
print(np.round(las.t, 2))
print('data:')
print(las)
shape:
(482981,)
attributes:
[('coords', '<f8', (3,)), ('intensity', '|u1'), ('classification', '|u1')]
projection:
+proj=utm +zone=32 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs
transformation matrix:
[[ 1.00000000e+00  0.00000000e+00  0.00000000e+00  3.64187980e+05]
 [ 0.00000000e+00  1.00000000e+00  0.00000000e+00  5.50957771e+06]
 [ 0.00000000e+00  0.00000000e+00  1.00000000e+00 -1.58000000e+00]
 [ 0.00000000e+00  0.00000000e+00  0.00000000e+00  1.00000000e+00]]
data:
[([ 3.64188062e+05,  5.50957772e+06, -1.28810938e+00], 216, 2)
 ([ 3.64187976e+05,  5.50957776e+06, -1.27100996e+00], 213, 2)
 ([ 3.64188095e+05,  5.50957776e+06, -1.30598884e+00], 214, 2) ...
 ([ 3.64193543e+05,  5.50958151e+06,  1.97219206e+01], 171, 5)
 ([ 3.64193519e+05,  5.50958148e+06,  1.97416807e+01], 160, 5)
 ([ 3.64193506e+05,  5.50958148e+06,  1.97642104e+01], 168, 5)]

Calculation of the height above ground

The LAS file provides some altitude values. But, we are interested in the height above ground instead. So, we select points representing the ground first, to fit a Digital Elevation Model (DEM). Using the DEM we calculate the height above ground for each point.

We use a DEM filter with a resolution of 0.5m. The filter selects local minima and guarantees a horizontal point distance of at least 0.5m and an altitude change between neighbored points below 50 degree.

[5]:
grd_ids = filters.dem_filter(las.coords, 0.5, max_angle=50)
print(grd_ids)
[ 97192  91536  96140  39010  94076  16662  10183 104165  85384  86854
 102761  38482  83712 102151 116834 105635 101280 118048 124970  87311
 108306  40907 123517 114405  13055 112076  24044  36647  51692  12269
  37402 256997 252300 266541  44324 109408  25316 265679 256605  32073
  45612 258610 252564 255461  42903 187875  27079  20457   6038 268661
 122507 271648  31654 167342 275851 260214 262363 189776 183189 276683
   5855 262318 261194 182891 278869  28162 162688 191363 290879 309455
 277489 272069 285002 196335 287382 283646 174355 319378 290570 312708
 165914 170659 192385 308441 199039 179555 314480 298770 195052 200959
 194685 220857 317956 212307 312093 211194 201875 203803 300082]

We have received a list of point indices which is used to select the desired representative ground points. So, we update the classification field for these points and plot them.

[6]:
las.classification[grd_ids] = 22
[7]:
plt.scatter(*las[las.classification == 22].coords[:, :2].T, color='black')
plt.xlabel('X (m)')
plt.ylabel('Y (m)')
plt.show()

../_images/tutorials_stem_detection_14_0.png

Now, we fit a DEM using a nearest neighbor interpolator.

[8]:
xy = las.coords[grd_ids, :2]
z = las.coords[grd_ids, 2]
dem = interpolate.KnnInterpolator(xy, z)

Finally, we calculate the height above ground and add a corresponding attribute to the point cloud.

[9]:
height = las.coords[:, 2] - dem(las.coords)
las = las.add_fields([('height', float)], data=[height])
print(las.dtype.descr)
[('coords', '<f8', (3,)), ('intensity', '|u1'), ('classification', '|u1'), ('height', '<f8')]

Filtering of stem points

Now, we try to filter the points subsequently until only points associated with stems remain.

We will focus on points with height above ground greater 0.5m only.

[10]:
fids = np.where(las.height > 0.5)[0]
print(len(fids))
251047

Due to the unnecessary high point density we filter the point cloud using a duplicate point filter. Only a subset of points with a point distance of at least 0.1m is kept.

[11]:
filter_gen = filters.ball(las[fids].indexKD(), 0.1)
fids = fids[list(filter_gen)]
print(len(fids))
12950

Let’s take a look at the remaining point cloud. To receive a nice plot, we define the axis limits first.

[12]:
axes_lims = Extent([
    las.extent().center - 0.5 * las.extent().ranges.max(),
    las.extent().center + 0.5 * las.extent().ranges.max()
])
[13]:
fig = plt.figure(figsize=(15, 15))
ax = plt.axes(projection='3d')
ax.set_xlim(axes_lims[0], axes_lims[3])
ax.set_ylim(axes_lims[1], axes_lims[4])
ax.set_zlim(axes_lims[2], axes_lims[5])
ax.set_xlabel('X (m)')
ax.set_ylabel('Y (m)')
ax.set_zlabel('Z (m)')

ax.plot_trisurf(*las[las.classification == 22].coords.T, cmap='gist_earth')
ax.scatter(*las[fids].coords.T, c=las.height[fids], cmap='coolwarm', marker='.')
plt.show()
../_images/tutorials_stem_detection_26_0.png

We can see four trees with linear stems. But, there are a lot of points associated with branches or leaves. We assign class 23 to the selected points for later visualization.

[14]:
las.classification[fids] = 23

To reduce noise, we only keep points with a lot of neighbors.

[15]:
count = las[fids].indexKD().ball_count(0.3)
fids = fids[count > 10]
print(len(fids))
las.classification[fids] = 24
10132
[16]:
fig = plt.figure(figsize=(15, 15))
ax = plt.axes(projection='3d')
ax.set_xlim(axes_lims[0], axes_lims[3])
ax.set_ylim(axes_lims[1], axes_lims[4])
ax.set_zlim(axes_lims[2], axes_lims[5])
ax.set_xlabel('X (m)')
ax.set_ylabel('Y (m)')
ax.set_zlabel('Z (m)')

ax.plot_trisurf(*las[las.classification == 22].coords.T, cmap='gist_earth')
ax.scatter(*las[las.classification == 23].coords.T, color='gray', marker='.')
ax.scatter(*las[las.classification == 24].coords.T, color='red', marker='.')
plt.show()
../_images/tutorials_stem_detection_31_0.png

That’s much better, but we are interested in the linear shape of the stems only. So, we filter with a radius of 1 m to remove similar points. This results in a point cloud with point distances of at least 1 m.

[17]:
filter_gen = filters.ball(las[fids].indexKD(), 1.0)
fids = fids[list(filter_gen)]
print(len(fids))
las.classification[fids] = 25
193
[18]:
fig = plt.figure(figsize=(15, 15))
ax = plt.axes(projection='3d')
ax.set_xlim(axes_lims[0], axes_lims[3])
ax.set_ylim(axes_lims[1], axes_lims[4])
ax.set_zlim(axes_lims[2], axes_lims[5])
ax.set_xlabel('X (m)')
ax.set_ylabel('Y (m)')
ax.set_zlabel('Z (m)')

ax.plot_trisurf(*las[las.classification == 22].coords.T, cmap='gist_earth')
ax.scatter(*las[las.classification == 25].coords.T, color='red')
plt.show()
../_images/tutorials_stem_detection_34_0.png

For dense point clouds, the filtering technique would result in point distances in a range of 1m to 2m. Thus, we can assume that linear arranged points should have 2 to 3 neighboring points within a radius of 1.5 m.

[19]:
count = las[fids].indexKD().ball_count(1.5)
fids = fids[np.all((count >= 2, count <= 3), axis=0)]
print(len(fids))
las.classification[fids] = 26
88
[20]:
fig = plt.figure(figsize=(15, 15))
ax = plt.axes(projection='3d')
ax.set_xlim(axes_lims[0], axes_lims[3])
ax.set_ylim(axes_lims[1], axes_lims[4])
ax.set_zlim(axes_lims[2], axes_lims[5])
ax.set_xlabel('X (m)')
ax.set_ylabel('Y (m)')
ax.set_zlabel('Z (m)')

ax.plot_trisurf(*las[las.classification == 22].coords.T, cmap='gist_earth')
ax.scatter(*las[las.classification == 25].coords.T, color='gray', marker='.')
ax.scatter(*las[las.classification == 26].coords.T, color='red')
plt.show()
../_images/tutorials_stem_detection_37_0.png

After applying the filters the stems are clearly visible. Thus, we can proceed with the actual detection of the stems.

Identification of stem clusters

We can detect the stems easily by clustering the previously filtered points. For this purpose we apply the DBSCAN algorithm.

[21]:
cluster_indices = clustering.dbscan(las[fids].indexKD(), 2, epsilon=1.5)
print('number of points:')
print(len(cluster_indices))
print('cluster IDs:')
print(np.unique(cluster_indices))
number of points:
88
cluster IDs:
[-1  0  1  2  3  4  5  6]

In the next step, we remove small clusters and unassigned points. In addition, we add an additional field, providing the tree ID.

[22]:
cluster_dict = classification.classes_to_dict(cluster_indices, ids=fids, min_size=5)
cluster_indices = classification.dict_to_classes(cluster_dict, len(las))
print('tree IDs:')
print(cluster_dict.keys())
print('cluster_dict:')
print(cluster_dict)
tree IDs:
dict_keys([2, 3, 5, 6])
cluster_dict:
{2: [371973, 371173, 369487, 368857, 366101, 365936, 160689, 159509, 156897, 155653, 152232, 148925, 147360, 144290, 142432, 139379, 137708, 133495, 131557, 90638], 3: [374660, 370651, 370361, 367940, 367297, 162020, 161457, 158619, 154987, 154022, 150829, 150016, 146010, 144831, 141239, 139771, 135805, 133929, 129305], 5: [335645, 77637, 76803, 74807, 73937, 71618, 69644, 69372, 67774, 65755, 63816, 62705, 60297, 59194, 56386, 54681, 19293], 6: [243995, 242571, 241219, 239584, 238103, 235757, 234354, 232652, 231017, 229229, 209774]}
[23]:
las = las.add_fields([('tree_id', int)])
las.tree_id[:] = -1
for tree_id in cluster_dict:
    las.tree_id[cluster_dict[tree_id]] = tree_id
[24]:
fig = plt.figure(figsize=(15, 15))
ax = plt.axes(projection='3d')
ax.set_xlim(axes_lims[0], axes_lims[3])
ax.set_ylim(axes_lims[1], axes_lims[4])
ax.set_zlim(axes_lims[2], axes_lims[5])
ax.set_xlabel('X (m)')
ax.set_ylabel('Y (m)')
ax.set_zlabel('Z (m)')

ax.scatter(*las[las.tree_id > -1].coords.T, c=las.tree_id[las.tree_id > -1], cmap='coolwarm')
plt.show()
../_images/tutorials_stem_detection_44_0.png

Fitting of stem vectors

To model the stems, we it a vector to each stem.

[25]:
stems = {
    tree_id: vector.Vector.from_coords(las[idx].coords)
    for tree_id, idx in cluster_dict.items()
}

Finally, we determinate the tree root coordinates of the stems by calculating the intersection points with the surface.

[26]:
roots = {
    tree_id: vector.vector_surface_intersection(stem, dem)
    for tree_id, stem in stems.items()
}
origins = { tree_id: stem.origin for tree_id, stem in stems.items()}
[27]:
fig = plt.figure(figsize=(15, 15))
ax = plt.axes(projection='3d')
ax.set_xlim(axes_lims[0], axes_lims[3])
ax.set_ylim(axes_lims[1], axes_lims[4])
ax.set_zlim(axes_lims[2], axes_lims[5])
ax.set_xlabel('X (m)')
ax.set_ylabel('Y (m)')
ax.set_zlabel('Z (m)')

ax.scatter(*las[las.tree_id > -1].coords.T, c=las.tree_id[las.tree_id > -1], cmap='Set1')
ax.scatter(*zip(*roots.values()), color='black', marker='X', s=40)
ax.scatter(*zip(*origins.values()), color='black', cmap='Set1', marker='^', s=40)
for tree_id in roots.keys():
    coords = np.vstack([roots[tree_id], origins[tree_id]])
    ax.plot(*coords.T, color='black')
plt.show()
../_images/tutorials_stem_detection_49_0.png