|
| 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 | +} |
0 commit comments