File size: 4,578 Bytes
26e5c1d
 
 
 
 
 
c758a23
 
 
 
26e5c1d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0d17b56
 
 
 
26e5c1d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0d17b56
 
26e5c1d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import numpy as np
from sklearn.cluster import DBSCAN
from scipy.spatial import ConvexHull
from scipy.optimize import least_squares

CLASSES = [
    'Betula pendula',
    'Fagus sylvatica', 
    'Picea abies',
    'Pinus sylvestris'
]

def fit_circle(x, y):
    """Fit a circle to given x, y points."""
    def calc_radius(params):
        cx, cy, r = params
        return np.sqrt((x - cx)**2 + (y - cy)**2) - r

    # Initial guess for circle parameters: center (mean x, mean y), radius
    x_m, y_m = np.mean(x), np.mean(y)
    r_initial = np.mean(np.sqrt((x - x_m)**2 + (y - y_m)**2))
    initial_params = [x_m, y_m, r_initial]

    # Use least squares optimization to fit the circle
    result = least_squares(calc_radius, initial_params)
    cx, cy, r = result.x
    return cx, cy, r
    
def remove_noise(points, eps=0.05, min_samples=10):
    """
    Remove noise from points using DBSCAN clustering.

    Args:
        points (numpy.ndarray): Array of shape (N, 3) with columns [x, y, z].
        eps (float): Maximum distance between two samples to consider them as in the same neighborhood.
        min_samples (int): Minimum number of points to form a dense region.
    
    Returns:
        numpy.ndarray: Denoised points.
    """
    clustering = DBSCAN(eps=eps, min_samples=min_samples).fit(points)
    labels = clustering.labels_
    try:
        largest_cluster = labels == np.argmax(np.bincount(labels[labels >= 0]))
    except Exception:
        raise RuntimeError("Error in DBH calculation")
    return points[largest_cluster]

def calculate_dbh(points, dbh_height=1.3, height_buffer=0.1, eps=0.05, min_samples=10):
    """
    Calculate the Diameter at Breast Height (DBH) of a tree from point cloud data.

    Args:
        points (numpy.ndarray): Array of shape (N, 3) with columns [x, y, z].
        dbh_height (float): Height at which DBH is measured (default is 1.3 meters).
        height_buffer (float): Range around dbh_height to include points (default is ±0.1 meters).
    
    Returns:
        float: DBH in meters.
    """
    z_min, z_max = dbh_height - height_buffer, dbh_height + height_buffer
    trunk_points = points[(points[:, 2] >= (z_min)) & (points[:, 2] <= (z_max))]
    
    if trunk_points.shape[0] < 3:
        raise ValueError("Not enough points to calculate DBH.")
    
    # Remove noise
    denoised_points = remove_noise(trunk_points[:, :2], eps=eps, min_samples=min_samples)
    denoised_points = np.hstack((denoised_points, np.full((denoised_points.shape[0], 1), dbh_height)))

    if denoised_points.shape[0] < 3:
        raise ValueError("Not enough points left after noise removal.")
    
    # Fit a circle to the trunk points
    x, y = denoised_points[:, 0], denoised_points[:, 1]
    cx, cy, radius = fit_circle(x, y)
    
    # Generate points along the fitted circle for visualization
    theta = np.linspace(0, 2 * np.pi, 100)
    circle_x = cx + radius * np.cos(theta)
    circle_y = cy + radius * np.sin(theta)
    circle_points = np.column_stack((circle_x, circle_y, np.full_like(circle_x, dbh_height)))
    
    # Calculate DBH (Diameter = 2 * radius)
    dbh = 2 * radius
    return dbh, circle_points

def calc_canopy_volume(points, threshold, height, z_min):
    '''
    Calculates the canopy points for a given point cloud data of a tree
    and calculates the volume using the Qhull algorithm
    
    Args:
        points: point cloud data
        threshold: z_threshold in percentage
        height, z_min
        
    Returns:
        canopy_volume, canopy_points
    '''
    z_threshold = z_min + (threshold / 100) * height
    canopy_points = points[points[:, 2] >= z_threshold]
    clustering = DBSCAN(eps=1.0, min_samples=10).fit(canopy_points[:, :3])
    labels = clustering.labels_
    canopy_points = canopy_points[labels != -1]
    
    if canopy_points.shape[0] < 4:
        canopy_volume = None
    else:
        '''
        Uses the QuickHull algorithm which uses a divide-and-conquer approach.
        It selects the 2 leftmost and rightmost points on a 2D plane; 
        These are part of the ConvexHull.
        Then it selects the point farthest away from the line joining the 
        above 2 points and adds it to the ConvexHull.
        The points enclosed within that shape cannot be part of the 
        ConvexHull and are ignored. This process is then repeated until 
        all points are either part of the ConvexHull or contained inside it.
        '''
        hull = ConvexHull(canopy_points)
        canopy_volume = hull.volume
    
    return canopy_volume, canopy_points