Skip to content

Commit 37d17ec

Browse files
committed
fit circle
1 parent a2a2cc8 commit 37d17ec

File tree

6 files changed

+197
-1
lines changed

6 files changed

+197
-1
lines changed

DESCRIPTION

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ Package: lidR
22
Type: Package
33
Title: Airborne LiDAR Data Manipulation and Visualization for Forestry
44
Applications
5-
Version: 4.1.3
5+
Version: 4.2.0
66
Authors@R: c(
77
person("Jean-Romain", "Roussel", email = "[email protected]", role = c("aut", "cre", "cph")),
88
person("David", "Auty", email = "", role = c("aut", "ctb"), comment = "Reviews the documentation"),
@@ -110,6 +110,7 @@ Collate:
110110
'engine_write.R'
111111
'fasterize.R'
112112
'filters.R'
113+
'fit_shapes.R'
113114
'fullwaveform.R'
114115
'generate_las.R'
115116
'io_readLAS.R'

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ export(filter_poi)
225225
export(filter_single)
226226
export(filter_surfacepoints)
227227
export(find_trees)
228+
export(fit_circle)
228229
export(forest.colors)
229230
export(gap_fraction_profile)
230231
export(get_lidr_threads)

NEWS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ v4.2.0 bring new tools for terrestrial data (TLS, MLS)
99
- New: new C++ spatial indexing class `SparsePartition3D` for TLS data which is memory optimized
1010
- New: new functions `readALS` and `readTLS` that replace overly complex and unused `readALSLAS`, `readTLSLAS`, `readUAVLAS` and co.
1111
- New: new functions `readALScatalog` and `readTLScatalog` that replace overly complex and unused `readALSLAScatalog`, `readTLSLAScatalog`, `readUAVLAScatalog` and co.
12+
- New: new function `fit_circle` using RANSAC approach.
1213
- Fix: #771 read VPC files with absolute path
1314
- Enhance: `crs()` `is.empty()` and `area()` are now inherits from `terra` and no longer clash with `terra`.
1415
- Enhance: #776 `readLAScatalog` can skip corrupted files

R/fit_shapes.R

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
fit_circle_on_3_points <- function(points_subset)
2+
{
3+
stopifnot(nrow(points_subset) == 3L, ncol(points_subset) > 2L)
4+
5+
# Extract the coordinates
6+
x1 <- points_subset[1, 1]
7+
y1 <- points_subset[1, 2]
8+
x2 <- points_subset[2, 1]
9+
y2 <- points_subset[2, 2]
10+
x3 <- points_subset[3, 1]
11+
y3 <- points_subset[3, 2]
12+
13+
# Calculate the coefficients for the linear system
14+
A <- 2 * (x2 - x1)
15+
B <- 2 * (y2 - y1)
16+
C <- x2^2 + y2^2 - x1^2 - y1^2
17+
D <- 2 * (x3 - x1)
18+
E <- 2 * (y3 - y1)
19+
G <- x3^2 + y3^2 - x1^2 - y1^2
20+
21+
# Solve for a and b using Cramer's rule
22+
denominator <- A * E - B * D
23+
if (denominator == 0)
24+
{
25+
return(c(0, 0, 0))
26+
}
27+
a <- (C * E - B * G) / denominator
28+
b <- (A * G - C * D) / denominator
29+
30+
# Calculate the radius
31+
r <- sqrt((x1 - a)^2 + (y1 - b)^2)
32+
33+
# Return the center and radius
34+
return(c(a, b, r))
35+
}
36+
37+
#' Fits 2D Circles on a Point Cloud
38+
#'
39+
#' Fits a 2D horizontally-aligned circle to a set of 3D points. The method uses RANSAC-based fitting
40+
#' for robust estimation. The function returns a list with the circle parameters and additional data
41+
#' to help determine whether the points form a circular shape. While it is always possible to fit a
42+
#' circle to any dataset, the additional information assists in evaluating if the data genuinely
43+
#' represent a circular pattern. The root mean square error (RMSE) may not always be a reliable metric
44+
#' for assessing the quality of the fit due to non-circular elements (such as branches) that can arbitrarily
45+
#' increase the RMSE of an otherwise well-fitted circle, as shown in the example below. Therefore, the
46+
#' function also returns the angular range of the data, which indicates the spread of the inlier points:
47+
#' 360 degrees suggests a full circle, 180 degrees indicates a half-circle, or a smaller range may
48+
#' suggest a partial arc.
49+
#'
50+
#' @param points A LAS object representing a clustered slice, or an nx3 matrix of point coordinates.
51+
#' @param num_iteration The number of iterations for the RANSAC fitting algorithm.
52+
#' @param inlier_threshold A threshold value; points are considered inliers if their residuals are
53+
#' below this value.
54+
#'
55+
#' @return A list containing the circle parameters and additional information for determining whether
56+
#' the data fit a circular shape:
57+
#' - `center_x`, `center_y`, `radius`: parameters of the fitted circle.
58+
#' - `z`: average elevation of the circle.
59+
#' - `rmse`: root mean square error between the circle and all points.
60+
#' - `angle_range`: angular sector covered by inlier points.
61+
#' - `inliers`: IDs of the inlier points.
62+
#' @md
63+
#' @export
64+
#' @examples
65+
#' LASfile <- system.file("extdata", "dbh.laz", package="lidR")
66+
#' las <- readTLS(LASfile, select = "xyz")
67+
#' xyz = sf::st_coordinates(las)
68+
#' circle = fit_circle(xyz)
69+
#' plot(xyz, asp = 1, pch = 19, cex = 0.1)
70+
#' symbols(circle$center_x, circle$center_y, circles = circle$radius,
71+
#' add = TRUE, fg = "red", inches = FALSE)
72+
#'
73+
#' trunk = las[circle$inliers]
74+
#'
75+
#' # Fitting a half-circle
76+
#' half = xyz[xyz[,1] > 101.45,]
77+
#' circle = fit_circle(half)
78+
#' plot(half, asp = 1, pch = 19, cex = 0.1)
79+
#' symbols(circle$center_x, circle$center_y, circles = circle$radius,
80+
#' add = TRUE, fg = "red", inches = FALSE)
81+
#' circle$angle_range
82+
fit_circle <- function(points, num_iterations = 500, inlier_threshold = 0.01)
83+
{
84+
best_circle <- NULL
85+
max_inliers <- 0
86+
87+
if (is(points, "LAS")) points = st_coordinates(points)
88+
89+
stopifnot(is.matrix(points), ncol(points) == 3L, nrow(points) > 3L)
90+
91+
z = points[, 3]
92+
93+
for (i in 1:num_iterations)
94+
{
95+
# Randomly sample points
96+
sample_indices <- sample(1:nrow(points), 3L)
97+
points_subset <- points[sample_indices, ]
98+
99+
params <- fit_circle_on_3_points(points_subset)
100+
101+
# Compute residuals for all points
102+
distances <- sqrt((points[, 1] - params[1])^2 + (points[, 2] - params[2])^2)
103+
residuals <- abs(distances - params[3])
104+
105+
# Count inliers (points whose residuals are below the threshold)
106+
inliers <- sum(residuals < inlier_threshold)
107+
108+
# Update best model if more inliers are found
109+
if (inliers > max_inliers)
110+
{
111+
max_inliers <- inliers
112+
best_circle <- params
113+
}
114+
}
115+
116+
center_x <- best_circle[1]
117+
center_y <- best_circle[2]
118+
radius <- best_circle[3]
119+
120+
# Goodness of fit
121+
distances <- sqrt((points[, 1] - center_x)^2 + (points[, 2] - center_y)^2)
122+
residuals <- abs(distances - radius)
123+
inliers <- residuals < inlier_threshold
124+
rmse <- sqrt(mean((residuals)^2))
125+
126+
# Angular range
127+
angle_res = 3
128+
angles <- atan2(points[inliers, 2] - center_y, points[inliers, 1] - center_x)
129+
angles <- ifelse(angles < 0, angles + 2 * pi, angles)
130+
angles <- sort(angles*180/pi)
131+
rangles <- unique(round(angles/angle_res)*angle_res)
132+
angle_range_degrees = sum(diff(rangles) <= angle_res)*angle_res
133+
134+
return(list(center_x = center_x, center_y = center_y, radius = radius, z = mean(z), rmse = rmse, angle_range = angle_range_degrees, inliers = which(inliers)))
135+
}

inst/extdata/dbh.laz

27.3 KB
Binary file not shown.

man/fit_circle.Rd

Lines changed: 58 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)