├── .gitignore ├── LICENSE ├── README.rst ├── building_boundary ├── __init__.py ├── building_boundary.py ├── core │ ├── __init__.py │ ├── inflate.py │ ├── intersect.py │ ├── merge.py │ ├── regularize.py │ ├── segment.py │ └── segmentation.py ├── footprint.py ├── shapes │ ├── __init__.py │ ├── alpha_shape.py │ ├── bounding_box.py │ ├── bounding_triangle.py │ └── fit.py └── utils │ ├── __init__.py │ ├── angle.py │ ├── error.py │ └── geometry.py ├── img ├── 1_boundary_points.png ├── 2_segmentation.png ├── 3_regularization.png ├── 4_intersections.png └── 5_result.png └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | .spyproject/ 2 | .vscode/ 3 | .pytest_cache/ 4 | data/ 5 | debug/ 6 | tests/ 7 | 8 | # Compiled python modules. 9 | *.pyc 10 | 11 | # Setuptools distribution folder. 12 | /dist/ 13 | 14 | # Python egg metadata, regenerated from source files by setuptools. 15 | /*.egg-info -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Geodan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Building Boundary 3 | ================= 4 | 5 | Traces the boundary of a set of points belonging to an aerial LiDAR scan of a building (part). It attempts to optimize the boundary by exploiting the (most often) rectilinearity of buildings. It will look for the primary orientations of the building and regularize all boundary lines to these orientations (or the perpendicular). 6 | 7 | The basic steps of the algorithm are as follows: 8 | 9 | 1. Determine the boundary points 10 | 2. Check if the shape matches a basic shape (rectangle or triangle), if so return this basic shape 11 | 3. Segment the boundary points in to wall segments 12 | 4. Fit a line to each segment 13 | 5. Determine the primary orientations 14 | 6. Regularize the lines to the primary orientations 15 | 7. Merge subsequent parallel lines 16 | 8. Compute the intersections of the lines 17 | 18 | .. raw:: html 19 | 20 | boundary points 21 | boundary points 22 | boundary points 23 | boundary points 24 | boundary points 25 | 26 | Prerequisites 27 | ============= 28 | 29 | - python >= 3.6 30 | - pip >= 19.1 31 | - concave-hull_ >= 1.0 32 | - pymintriangle_ >= 0.1 33 | - CGAL (with SWIG python bindings) >= 4.12 (optional, drastically improves computation time of alpha shapes) 34 | 35 | .. _concave-hull: https://github.com/Geodan/concave-hull 36 | .. _pymintriangle: https://github.com/Geodan/pymintriangle 37 | 38 | Install 39 | ======= 40 | 41 | .. code-block:: sh 42 | 43 | pip install . 44 | 45 | Usage 46 | ===== 47 | 48 | .. code-block:: python 49 | 50 | import numpy as np 51 | import building_boundary 52 | 53 | points = np.array([ 54 | [122336.637, 489292.815], 55 | [122336.233, 489291.98 ], 56 | [122336.258, 489292.865], 57 | [122335.234, 489293.104], 58 | [122336.448, 489293.46 ], 59 | [122334.992, 489293.68 ], 60 | [122335.987, 489292.778], 61 | [122335.383, 489292.746], 62 | [122336.509, 489293.173], 63 | [122335.794, 489293.425], 64 | [122335.562, 489293.121], 65 | [122335.469, 489293.406], 66 | [122335.944, 489293.734], 67 | [122335.3 , 489293.697], 68 | [122336.574, 489292.414], 69 | [122336.2 , 489292.31 ], 70 | [122335.907, 489292.296], 71 | [122335.599, 489292.281], 72 | [122335.686, 489292.762], 73 | [122336.842, 489293.192], 74 | [122335.886, 489293.139], 75 | [122335.094, 489292.733], 76 | [122336.146, 489293.444], 77 | [122336.193, 489293.157], 78 | [122335.154, 489293.389], 79 | [122335.643, 489293.717] 80 | ]) 81 | vertices = building_boundary.trace_boundary( 82 | points, 83 | 0.3, 84 | max_error=0.4, 85 | alpha=0.5, 86 | k=5, 87 | num_points=10, 88 | merge_distance=0.6 89 | ) 90 | 91 | Documentation 92 | ============= 93 | 94 | trace_boundary 95 | ~~~~~~~~~~~~~~ 96 | 97 | Trace the boundary of a set of 2D points. 98 | 99 | Parameters 100 | ---------- 101 | points : (Mx2) array 102 | The coordinates of the points. 103 | ransac_threshold : float 104 | Maximum distance for a data point to be classified as an inlier during 105 | the RANSAC line fitting. 106 | max_error : float 107 | The maximum error (distance) a point can have to a computed line. 108 | alpha : float 109 | Set to determine the boundary points using an alpha shape using this 110 | chosen alpha. If both alpha and k are set both methods will be used and 111 | the resulting shapes merged to find the boundary points. 112 | k : int 113 | Set to determine the boundary points using a knn based concave hull 114 | algorithm using this amount of nearest neighbors. If both alpha and k 115 | are set both methods will be used and the resulting shapes merged to 116 | find the boundary points. 117 | num_points : int, optional 118 | The number of points a segment needs to be supported by to be 119 | considered a primary orientation. Will be ignored if primary 120 | orientations are set manually. 121 | angle_epsilon : float, optional 122 | The angle (in radians) difference within two angles are considered the 123 | same. Used to merge segments. 124 | merge_distance : float, optional 125 | If the distance between two parallel sequential segments (based on the 126 | angle epsilon) is lower than this value the segments get merged. 127 | primary_orientations : list of floats, optional 128 | The desired primary orientations (in radians) of the boundary. If set 129 | manually here these orientations will not be computed. 130 | perp_dist_weight : float, optional 131 | Used during the computation of the intersections between the segments. 132 | If the distance between the intersection of two segments and the 133 | segments is more than `perp_dist_weight` times the distance between the 134 | intersection of the perpendicular line at the end of the line segment 135 | and the segments, the perpendicular intersection will be used instead. 136 | inflate : bool, optional 137 | If set to true the fit lines will be moved to the furthest outside 138 | point. 139 | 140 | Returns 141 | ------- 142 | vertices : (Mx2) array 143 | The vertices of the computed boundary line 144 | -------------------------------------------------------------------------------- /building_boundary/__init__.py: -------------------------------------------------------------------------------- 1 | from .building_boundary import trace_boundary 2 | from .shapes import fit 3 | from .core import intersect 4 | from .core import regularize 5 | from .core import segment 6 | from .core import segmentation 7 | from .core import merge 8 | from .core import inflate 9 | from . import utils 10 | from . import footprint 11 | 12 | 13 | __all__ = [ 14 | 'trace_boundary', 15 | 'intersect', 16 | 'regularize', 17 | 'segment', 18 | 'segmentation', 19 | 'fit' 20 | 'merge', 21 | 'inflate', 22 | 'utils', 23 | 'footprint' 24 | ] 25 | -------------------------------------------------------------------------------- /building_boundary/building_boundary.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | @author: Chris Lucas 5 | """ 6 | 7 | import numpy as np 8 | from shapely.geometry import Polygon 9 | 10 | from .shapes.fit import compute_shape, fit_basic_shape 11 | from .core.segment import BoundarySegment 12 | from .core.segmentation import boundary_segmentation 13 | from .core.merge import merge_segments 14 | from .core.intersect import compute_intersections 15 | from .core.regularize import get_primary_orientations, regularize_segments 16 | from .core.inflate import inflate_polygon 17 | from . import utils 18 | 19 | 20 | def trace_boundary(points, ransac_threshold, max_error=None, alpha=None, 21 | k=None, num_points=None, angle_epsilon=0.05, 22 | merge_distance=None, primary_orientations=None, 23 | perp_dist_weight=3, inflate=False): 24 | """ 25 | Trace the boundary of a set of 2D points. 26 | 27 | Parameters 28 | ---------- 29 | points : (Mx2) array 30 | The coordinates of the points. 31 | ransac_threshold : float 32 | Maximum distance for a data point to be classified as an inlier during 33 | the RANSAC line fitting. 34 | max_error : float 35 | The maximum error (distance) a point can have to a computed line. 36 | alpha : float 37 | Set to determine the boundary points using an alpha shape using this 38 | chosen alpha. If both alpha and k are set both methods will be used and 39 | the resulting shapes merged to find the boundary points. 40 | k : int 41 | Set to determine the boundary points using a knn based concave hull 42 | algorithm using this amount of nearest neighbors. If both alpha and k 43 | are set both methods will be used and the resulting shapes merged to 44 | find the boundary points. 45 | num_points : int, optional 46 | The number of points a segment needs to be supported by to be 47 | considered a primary orientation. Will be ignored if primary 48 | orientations are set manually. 49 | angle_epsilon : float 50 | The angle (in radians) difference within two angles are considered the 51 | same. Used to merge segments. 52 | merge_distance : float 53 | If the distance between two parallel sequential segments (based on the 54 | angle epsilon) is lower than this value the segments get merged. 55 | primary_orientations : list of floats, optional 56 | The desired primary orientations (in radians) of the boundary. If set 57 | manually here these orientations will not be computed. 58 | perp_dist_weight : float 59 | Used during the computation of the intersections between the segments. 60 | If the distance between the intersection of two segments and the 61 | segments is more than `perp_dist_weight` times the distance between the 62 | intersection of the perpendicular line at the end of the line segment 63 | and the segments, the perpendicular intersection will be used instead. 64 | inflate : bool 65 | If set to true the fit lines will be moved to the furthest outside 66 | point. 67 | 68 | Returns 69 | ------- 70 | vertices : (Mx2) array 71 | The vertices of the computed boundary line 72 | """ 73 | shape = compute_shape(points, alpha=alpha, k=k) 74 | boundary_points = np.array(shape.exterior.coords) 75 | 76 | basic_shape, basic_shape_fits = fit_basic_shape( 77 | shape, 78 | max_error=max_error, 79 | given_angles=primary_orientations, 80 | ) 81 | if max_error is not None and basic_shape_fits: 82 | return np.array(basic_shape.exterior.coords) 83 | 84 | segments = boundary_segmentation(boundary_points, ransac_threshold) 85 | 86 | if len(segments) in [0, 1, 2]: 87 | return np.array(basic_shape.exterior.coords) 88 | 89 | boundary_segments = [BoundarySegment(s) for s in segments] 90 | 91 | if primary_orientations is None or len(primary_orientations) == 0: 92 | primary_orientations = get_primary_orientations( 93 | boundary_segments, 94 | num_points, 95 | angle_epsilon=angle_epsilon 96 | ) 97 | 98 | if len(primary_orientations) == 1: 99 | primary_orientations.append( 100 | utils.angle.perpendicular(primary_orientations[0]) 101 | ) 102 | 103 | boundary_segments = regularize_segments(boundary_segments, 104 | primary_orientations, 105 | max_error=max_error) 106 | 107 | boundary_segments = merge_segments(boundary_segments, 108 | angle_epsilon=angle_epsilon, 109 | max_distance=merge_distance, 110 | max_error=max_error) 111 | 112 | vertices = compute_intersections(boundary_segments, 113 | perp_dist_weight=perp_dist_weight) 114 | 115 | if inflate: 116 | remaining_points = boundary_segments[0].points 117 | for s in boundary_segments[1:]: 118 | remaining_points = np.vstack((remaining_points, s.points)) 119 | vertices = inflate_polygon(vertices, remaining_points) 120 | 121 | polygon = Polygon(vertices) 122 | if not polygon.is_valid: 123 | return np.array(basic_shape.exterior.coords) 124 | 125 | if (len(boundary_segments) == len(basic_shape.exterior.coords)-1 and 126 | basic_shape.area < polygon.area): 127 | return np.array(basic_shape.exterior.coords) 128 | 129 | return vertices 130 | -------------------------------------------------------------------------------- /building_boundary/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geodan/building-boundary/d0eb88d99743af917568131e8609f481b10e4520/building_boundary/core/__init__.py -------------------------------------------------------------------------------- /building_boundary/core/inflate.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | @author: Chris Lucas 5 | """ 6 | 7 | import numpy as np 8 | from shapely.geometry import Polygon, Point 9 | from shapely.ops import nearest_points 10 | 11 | from .segment import BoundarySegment 12 | from .. import utils 13 | 14 | 15 | def point_on_line_segment(line_segment, point): 16 | """ 17 | Determines if a point is on a line defined by two points. 18 | 19 | Parameters 20 | ---------- 21 | line_segment : (2x2) array 22 | A line defined by the coordinates of two points. 23 | point : (1x2) array 24 | The coordinates of the point to check 25 | 26 | Returns 27 | ------- 28 | : bool 29 | If the point is on the line defined by two points. 30 | """ 31 | a = line_segment[0] 32 | b = line_segment[1] 33 | p = point 34 | if not np.isclose(np.cross(b-a, p-a), 0): 35 | return False 36 | else: 37 | dist_ab = utils.geometry.distance(a, b) 38 | dist_ap = utils.geometry.distance(a, p) 39 | dist_bp = utils.geometry.distance(b, p) 40 | if np.isclose(dist_ap + dist_bp, dist_ab): 41 | return True 42 | else: 43 | return False 44 | 45 | 46 | def point_polygon_edges(point_on_polygon, edges): 47 | """ 48 | Determines on which edge(s) of a polygon a point (which is on the polygon's 49 | exterior) lies. 50 | 51 | Parameters 52 | ---------- 53 | point_on_polygon : (1x2) array 54 | The coordinates of the point. 55 | edges : list of tuple of (1x2) array 56 | Each tuple contains the coordinates of the start and end point of an 57 | edge of the polygon. 58 | 59 | Returns 60 | ------- 61 | found_edges : list of int 62 | The indices of the edges on which the given point lies. Often of 63 | length 1, however if a point lies on a corner it lies on two edges 64 | and two indices will be returned. 65 | """ 66 | found_edges = [] 67 | for i, e in enumerate(edges): 68 | if point_on_line_segment(e, point_on_polygon): 69 | found_edges.append(i) 70 | return found_edges 71 | 72 | 73 | def inflate_polygon(vertices, points): 74 | """ 75 | Inflates the polygon such that it will contain all the given points. 76 | 77 | Parameters 78 | ---------- 79 | vertices : (Mx2) array 80 | The coordinates of the vertices of the polygon. 81 | points : (Mx2) array 82 | The coordinates of the points that the polygon should contain. 83 | 84 | Returns 85 | ------- 86 | vertices : (Mx2) array 87 | The coordinates of the vertices of the inflated polygon. 88 | """ 89 | new_vertices = vertices.copy() 90 | points_geom = [Point(p) for p in points] 91 | n_vertices = len(vertices) 92 | polygon = Polygon(vertices) 93 | edges = utils.create_pairs(new_vertices) 94 | 95 | # Find points not enclosed in polygon 96 | distances = np.array([polygon.distance(p) for p in points_geom]) 97 | outliers_mask = np.invert(np.isclose(distances, 0)) 98 | outliers = points[outliers_mask] 99 | distances = distances[outliers_mask] 100 | n_outliers = len(outliers) 101 | 102 | while n_outliers > 0: 103 | p = outliers[np.argmax(distances)] 104 | 105 | # Find nearest polygon edge to point 106 | point_on_polygon, _ = nearest_points(polygon, Point(p)) 107 | point_on_polygon = np.array(point_on_polygon) 108 | nearest_edges = point_polygon_edges(point_on_polygon, edges) 109 | 110 | # Move polygon edge out such that point is enclosed 111 | for i in nearest_edges: 112 | delta = p - point_on_polygon 113 | p1 = new_vertices[i] + delta 114 | p2 = new_vertices[(i+1) % n_vertices] + delta 115 | # Lines 116 | l1 = BoundarySegment(np.array([new_vertices[(i-1) % n_vertices], 117 | new_vertices[i]])) 118 | l2 = BoundarySegment(np.array([p1, p2])) 119 | l3 = BoundarySegment(np.array([new_vertices[(i+1) % n_vertices], 120 | new_vertices[(i+2) % n_vertices]])) 121 | # Intersections 122 | new_vertices[i] = l2.line_intersect(l1.line) 123 | new_vertices[(i+1) % n_vertices] = l2.line_intersect(l3.line) 124 | 125 | # Update polygon 126 | polygon = Polygon(new_vertices) 127 | 128 | if not polygon.is_valid: 129 | polygon = polygon.buffer(0) 130 | new_vertices = np.array(polygon.exterior.coords) 131 | n_vertices = len(new_vertices) 132 | n_outliers = float('inf') 133 | 134 | edges = utils.create_pairs(new_vertices) 135 | point_on_polygon, _ = nearest_points(polygon, Point(p)) 136 | point_on_polygon = np.array(point_on_polygon) 137 | 138 | distances = np.array([polygon.distance(p) for p in points_geom]) 139 | outliers_mask = np.invert(np.isclose(distances, 0)) 140 | outliers = points[outliers_mask] 141 | distances = distances[outliers_mask] 142 | 143 | if len(outliers) >= n_outliers: 144 | break 145 | n_outliers = len(outliers) 146 | 147 | if not Polygon(new_vertices).is_valid and Polygon(vertices).is_valid: 148 | return vertices 149 | else: 150 | return new_vertices 151 | -------------------------------------------------------------------------------- /building_boundary/core/intersect.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | @author: Chris Lucas 5 | """ 6 | 7 | import numpy as np 8 | 9 | from .. import utils 10 | 11 | 12 | def perpedicular_line_intersect(segment1, segment2): 13 | """ 14 | Find the intersection of the perpendicular line at the end of 15 | segment 1 and the line of segment 2. 16 | 17 | Parameters 18 | ---------- 19 | segment1 : BoundarySegment 20 | A BoundarySegment 21 | segment2 : BoundarySegment 22 | A subsequent BoundarySegment 23 | 24 | Returns 25 | ------- 26 | : (1x2) array 27 | The coordinates of the intersection. 28 | """ 29 | perp_line = utils.geometry.perpedicular_line(segment1.line, 30 | segment1.end_points[1]) 31 | return segment2.line_intersect(perp_line) 32 | 33 | 34 | def intersect_distance(intersect, segment1, segment2): 35 | """ 36 | The distance between the segments and the intersection. 37 | 38 | Parameters 39 | ---------- 40 | intersect : (1x2) array 41 | The coordinates of the intersection. 42 | segment1 : BoundarySegment 43 | A BoundarySegment 44 | segment2 : BoundarySegment 45 | A subsequent BoundarySegment 46 | 47 | Returns 48 | ------- 49 | : float 50 | The distance between the segments and intersection. 51 | """ 52 | return min(utils.geometry.distance(segment1.end_points[1], intersect), 53 | utils.geometry.distance(segment2.end_points[0], intersect)) 54 | 55 | 56 | def compute_intersections(segments, perp_dist_weight=3): 57 | """ 58 | Computes the intersections between the segments in sequence. If 59 | no intersection could be found or the perpendicular line results in 60 | an intersection closer to the segments a perpendicular line will be 61 | added in between the two segments. 62 | 63 | Parameters 64 | ---------- 65 | segments : list of BoundarySegment 66 | The wall segments to compute intersections for. 67 | perp_dist_weight : float, optional 68 | How much closer a perpendicular line intersection needs to be to 69 | be preferred over the intersection between the two segments. 70 | 71 | Returns 72 | ------- 73 | intersections : (Mx1) array 74 | The computed intersections. 75 | """ 76 | intersections = [] 77 | num_segments = len(segments) 78 | for i in range(num_segments): 79 | segment1 = segments[i] 80 | segment2 = segments[(i + 1) % num_segments] 81 | 82 | intersect = segment1.line_intersect(segment2.line) 83 | 84 | if any(intersect): 85 | intersect_dist = intersect_distance(intersect, segment1, segment2) 86 | perp_intersect = perpedicular_line_intersect(segment1, segment2) 87 | if any(perp_intersect): 88 | perp_intersect_dist = intersect_distance(perp_intersect, 89 | segment1, 90 | segment2) 91 | if ((intersect_dist > 92 | perp_intersect_dist * perp_dist_weight) or 93 | (segment2.side_point_on_line(intersect) == -1 and 94 | perp_intersect_dist < 95 | intersect_dist * perp_dist_weight)): 96 | intersections.append(segment1.end_points[1]) 97 | intersections.append(perp_intersect) 98 | else: 99 | intersections.append(intersect) 100 | else: 101 | intersections.append(intersect) 102 | else: 103 | # if no intersection was found add a perpendicular line at the end 104 | # and intersect using the new line 105 | intersect = perpedicular_line_intersect(segment1, segment2) 106 | intersections.append(segment1.end_points[1]) 107 | intersections.append(intersect) 108 | 109 | return np.array(intersections) 110 | -------------------------------------------------------------------------------- /building_boundary/core/merge.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | @author: Chris Lucas 5 | """ 6 | 7 | import bisect 8 | 9 | import numpy as np 10 | 11 | from .. import utils 12 | from .segment import BoundarySegment 13 | from .intersect import perpedicular_line_intersect 14 | 15 | 16 | def find_pivots(orientations, angle): 17 | """ 18 | Finds the indices of where the difference in orientation is 19 | larger than the given angle. 20 | 21 | Parameters 22 | ---------- 23 | orientations : list of float 24 | The sequence of orientations 25 | angle : float or int 26 | The difference in angle at which a point will be considered a 27 | pivot. 28 | 29 | Returns 30 | ------- 31 | pivot_indices : list of int 32 | """ 33 | ori_diff = np.fromiter((utils.angle.angle_difference(a1, a2) for 34 | a1, a2 in utils.create_pairs(orientations)), 35 | orientations.dtype) 36 | pivots_bool = ori_diff > angle 37 | pivots_idx = list(np.where(pivots_bool)[0] + 1) 38 | 39 | # edge case 40 | if pivots_idx[-1] > (len(orientations)-1): 41 | del pivots_idx[-1] 42 | pivots_idx[0:0] = [0] 43 | 44 | return pivots_idx 45 | 46 | 47 | def get_points_between_pivots(segments, pivots): 48 | """ 49 | Returns the points between two pivot points 50 | 51 | Parameters 52 | ---------- 53 | segments : list of BoundarySegment 54 | The segments. 55 | pivots : list of int 56 | The indices of the pivot points. 57 | 58 | Returns 59 | ------- 60 | points : (Mx2) array 61 | """ 62 | k, n = pivots 63 | points = [segments[k].points[0]] 64 | if k < n: 65 | for s in segments[k:n]: 66 | points.extend(s.points[1:]) 67 | else: # edge case 68 | for s in segments[k:]: 69 | points.extend(s.points[1:]) 70 | for s in segments[:n]: 71 | points.extend(s.points[1:]) 72 | 73 | return np.array(points) 74 | 75 | 76 | def get_segments_between_pivots(segments, pivots): 77 | """ 78 | Graps the segments between two pivots. 79 | 80 | Parameters 81 | ---------- 82 | segments : list of BoundarySegment 83 | The segments. 84 | pivots : list of int 85 | The indices of the pivot points. 86 | 87 | Returns 88 | ------- 89 | segments : list of int 90 | The indices of the segments between the pivots. 91 | """ 92 | k, n = pivots 93 | if k < n: 94 | return list(range(k, n)) 95 | else: # edge case 96 | segments_pivots = list(range(k, len(segments))) 97 | segments_pivots.extend(range(0, n)) 98 | return segments_pivots 99 | 100 | 101 | def parallel_distance(segment1, segment2): 102 | """ 103 | Computes the distance between two parallel segments. 104 | 105 | Parameters 106 | ---------- 107 | segment1 : BoundarySegment 108 | A BoundarySegment. 109 | segment2 : BoundarySegment 110 | A subsequent BoundarySegment. 111 | 112 | Returns 113 | ------- 114 | distance : float 115 | The distance between the two segments measured from the end point of 116 | segment 1 in a perpendicular line to the line of segment 2. 117 | """ 118 | intersect = perpedicular_line_intersect(segment1, segment2) 119 | if len(intersect) > 0: 120 | return utils.geometry.distance(segment1.end_points[1], intersect) 121 | else: 122 | return float('inf') 123 | 124 | 125 | def check_distance(segments, pivots, max_distance): 126 | """ 127 | Checks if the distance between all subsequent parallel segments 128 | is larger than a given max, and inserts a pivot if this is the case. 129 | 130 | Parameters 131 | ---------- 132 | segments : list of BoundarySegment 133 | The segments. 134 | pivots : list of int 135 | The indices of the pivot points. 136 | max_distance : float 137 | The maximum distance two parallel subsequent segments may be to be 138 | merged. 139 | 140 | Returns 141 | ------- 142 | pivots : list of int 143 | The indices of the pivot points. 144 | """ 145 | distances = [] 146 | for i, pair in enumerate(utils.create_pairs(segments)): 147 | if (i + 1) % len(segments) not in pivots: 148 | distances.append(parallel_distance(pair[0], pair[1])) 149 | else: 150 | distances.append(float('nan')) 151 | too_far = np.where(np.array(distances) > max_distance)[0] + 1 152 | if len(too_far) > 0: 153 | too_far[-1] = too_far[-1] % len(segments) 154 | for x in too_far: 155 | bisect.insort_left(pivots, x) 156 | return pivots, distances 157 | 158 | 159 | def merge_between_pivots(segments, start, end, max_error=None): 160 | """ 161 | Merge the segments between two pivots. 162 | 163 | Parameters 164 | ---------- 165 | segments : list of BoundarySegment 166 | The segments. 167 | start : int 168 | The first segment index. 169 | end : int 170 | Till which index segments should be merged. 171 | max_error : float 172 | The maximum error (distance) a point can have to a computed line. 173 | 174 | Returns 175 | ------- 176 | merged_segment : BoundarySegment 177 | The segments merged. 178 | """ 179 | if end == start + 1: 180 | return segments[start] 181 | else: 182 | points = get_points_between_pivots( 183 | segments, 184 | [start, end] 185 | ) 186 | 187 | merged_segment = BoundarySegment(points) 188 | merge_segments = np.array(segments)[get_segments_between_pivots( 189 | segments, [start, end] 190 | )] 191 | longest_segment = max(merge_segments, key=lambda s: s.length) 192 | orientation = longest_segment.orientation 193 | merged_segment.regularize(orientation, max_error=max_error) 194 | return merged_segment 195 | 196 | 197 | def merge_segments(segments, angle_epsilon=0.05, 198 | max_distance=None, max_error=None): 199 | """ 200 | Merges segments which are within a given angle of each 201 | other. 202 | 203 | Parameters 204 | ---------- 205 | segments : list of BoundarySegment 206 | The segments. 207 | angle_epsilon : float 208 | The angle (in radians) difference within two angles are considered the 209 | same. 210 | max_distance : float 211 | If the distance between two parallel sequential segments (based on the 212 | angle epsilon) is lower than this value the segments get merged. 213 | max_error : float 214 | The maximum error (distance) a point can have to a computed line. 215 | 216 | Returns 217 | ------- 218 | segments : list of BoundarySegment 219 | The new set of segments 220 | """ 221 | orientations = np.array([s.orientation for s in segments]) 222 | pivots = find_pivots(orientations, angle_epsilon) 223 | 224 | if max_distance is not None: 225 | pivots, distances = check_distance(segments, pivots, max_distance) 226 | 227 | while True: 228 | new_segments = [] 229 | try: 230 | for pivot_segment in utils.create_pairs(pivots): 231 | new_segment = merge_between_pivots( 232 | segments, pivot_segment[0], pivot_segment[1], max_error 233 | ) 234 | new_segments.append(new_segment) 235 | break 236 | except utils.error.ThresholdError: 237 | segments_idx = get_segments_between_pivots(segments, pivot_segment) 238 | new_pivot_1 = segments_idx[ 239 | np.nanargmax(np.array(distances)[segments_idx]) 240 | ] 241 | new_pivot_2 = (new_pivot_1 + 1) % len(segments) 242 | if new_pivot_1 not in pivots: 243 | bisect.insort_left(pivots, new_pivot_1) 244 | if new_pivot_2 not in pivots: 245 | bisect.insort_left(pivots, new_pivot_2) 246 | 247 | return new_segments 248 | -------------------------------------------------------------------------------- /building_boundary/core/regularize.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | @author: Chris Lucas 5 | """ 6 | 7 | import math 8 | 9 | import numpy as np 10 | 11 | from .. import utils 12 | 13 | 14 | def get_primary_segments(segments, num_points): 15 | """ 16 | Checks the segments and returns the segments which are supported 17 | by at least the given number of points. 18 | 19 | Parameters 20 | ---------- 21 | segments : list of BoundarySegment 22 | The boundary (wall) segments of the building (part). 23 | num_points : int, optional 24 | The minimum number of points a segment needs to be supported by 25 | to be considered a primary segment. 26 | 27 | Returns 28 | ------- 29 | primary_segments : list of segments 30 | The segments which are supported by at least the given number of 31 | points. 32 | """ 33 | primary_segments = [s for s in segments if len(s.points) >= num_points] 34 | return primary_segments 35 | 36 | 37 | def find_main_orientation(segments): 38 | """ 39 | Checks which segment is supported by the most points and returns 40 | the orientation of this segment. 41 | 42 | Parameters 43 | ---------- 44 | segments : list of BoundarySegment 45 | The boundary (wall) segments of the building (part). 46 | 47 | Returns 48 | ------- 49 | main_orientation : float 50 | The orientation of the segment supported by the most points. 51 | In radians. 52 | """ 53 | longest_segment = np.argmax([len(s.points) for s in segments]) 54 | main_orientation = segments[longest_segment].orientation 55 | return main_orientation 56 | 57 | 58 | def sort_orientations(orientations): 59 | """ 60 | Sort orientations by the length of the segments which have that 61 | orientation. 62 | 63 | Parameters 64 | ---------- 65 | orientations : dict 66 | The orientations and corrisponding lengths 67 | 68 | Returns 69 | ------- 70 | sorted_orientations : list of float 71 | 72 | """ 73 | unsorted_orientations = [o['orientation'] for o in orientations] 74 | lengths = [o['size'] for o in orientations] 75 | sort = np.argsort(lengths)[::-1] 76 | sorted_orientations = np.array(unsorted_orientations)[sort].tolist() 77 | return sorted_orientations 78 | 79 | 80 | def compute_primary_orientations(primary_segments, angle_epsilon=0.05): 81 | """ 82 | Computes the primary orientations based on the given primary segments. 83 | 84 | Parameters 85 | ---------- 86 | primary_segments : list of BoundarySegment 87 | The primary segments. 88 | angle_epsilon : float, optional 89 | Angles will be considered equal if the difference is within 90 | this value (in radians). 91 | 92 | Returns 93 | ------- 94 | primary_orientations : list of float 95 | The computed primary orientations in radians, sorted by the length 96 | of the segments which have that orientation. 97 | """ 98 | orientations = [] 99 | 100 | for s in primary_segments: 101 | a1 = s.orientation 102 | for o in orientations: 103 | a2 = o['orientation'] 104 | angle_diff = utils.angle.min_angle_difference(a1, a2) 105 | if angle_diff < angle_epsilon: 106 | if len(s.points) > o['size']: 107 | o['size'] = len(s.points) 108 | o['orientation'] = a1 109 | break 110 | else: 111 | orientations.append({'orientation': a1, 112 | 'size': len(s.points)}) 113 | 114 | primary_orientations = sort_orientations(orientations) 115 | 116 | return primary_orientations 117 | 118 | 119 | def check_perpendicular(primary_orientations, angle_epsilon=0.05): 120 | """ 121 | Checks if a perpendicular orientation to the main orientation 122 | exists. 123 | 124 | Parameters 125 | ---------- 126 | primary_orientations : list of floats 127 | The primary orientations, where the first orientation in the 128 | list is the main orientation (in radians). 129 | angle_epsilon : float, optional 130 | Angles will be considered equal if the difference is within 131 | this value (in radians). 132 | 133 | Returns 134 | ------- 135 | : int 136 | The index of the perpendicular orientation to the main orientation. 137 | Returns -1 if no perpendicular orientation was found. 138 | """ 139 | main_orientation = primary_orientations[0] 140 | diffs = [utils.angle.min_angle_difference(main_orientation, a) 141 | for a in primary_orientations[1:]] 142 | diffs_perp = np.array(diffs) - math.pi/2 143 | closest_to_perp = np.argmin(np.abs(diffs_perp)) 144 | if diffs_perp[closest_to_perp] < angle_epsilon: 145 | return closest_to_perp + 1 146 | else: 147 | return -1 148 | 149 | 150 | def add_perpendicular(primary_orientations, angle_epsilon=0.05): 151 | """ 152 | Adds an orientation perpendicular to the main orientation if no 153 | approximate perpendicular orientation is present in the primary 154 | orientations. 155 | 156 | Parameters 157 | ---------- 158 | primary_orientations : list of floats 159 | The primary orientations, where the first orientation in the 160 | list is the main orientation (in radians). 161 | angle_epsilon : float, optional 162 | Angles will be considered equal if the difference is within 163 | this value (in radians). 164 | 165 | Returns 166 | ------- 167 | primary_orientations : list of floats 168 | The refined primary orientations 169 | """ 170 | main_orientation = primary_orientations[0] 171 | # if only one primary orientation is found, add an orientation 172 | # perpendicular to it. 173 | if len(primary_orientations) == 1: 174 | primary_orientations.append( 175 | utils.angle.perpendicular(main_orientation) 176 | ) 177 | else: 178 | perp_idx = check_perpendicular(primary_orientations, 179 | angle_epsilon=angle_epsilon) 180 | perp_orientation = utils.angle.perpendicular(main_orientation) 181 | # add a perpendicular orientation if no approximate perpendicular 182 | # orientations were found 183 | if perp_idx == -1: 184 | primary_orientations.append(perp_orientation) 185 | # or set found approximate perpendicular orientation to exactly 186 | # perpendicular 187 | else: 188 | primary_orientations[perp_idx] = perp_orientation 189 | 190 | return primary_orientations 191 | 192 | 193 | def get_primary_orientations(segments, num_points=None, 194 | angle_epsilon=0.05): 195 | """ 196 | Computes the primary orientations of the building by checking the 197 | number of points it is supported by. If multiple orientations are 198 | found which are very close to each other, a mean orientation will be 199 | taken. If no primary orientations can be found, the orientation of the 200 | segment supported by the most points will be taken. 201 | 202 | Parameters 203 | ---------- 204 | segments : list of BoundarySegment 205 | The boundary (wall) segments of the building (part). 206 | num_points : int, optional 207 | The minimum number of points a segment needs to be supported by 208 | to be considered a primary segment. 209 | angle_epsilon : float, optional 210 | Angles will be considered equal if the difference is within 211 | this value (in radians). 212 | 213 | Returns 214 | ------- 215 | primary_orientations : list of float 216 | The computed primary orientations in radians. 217 | """ 218 | if num_points is not None: 219 | primary_segments = get_primary_segments(segments, num_points) 220 | else: 221 | primary_segments = [] 222 | 223 | if len(primary_segments) > 0: 224 | primary_orientations = compute_primary_orientations(primary_segments, 225 | angle_epsilon) 226 | else: 227 | primary_orientations = [find_main_orientation(segments)] 228 | 229 | primary_orientations = add_perpendicular(primary_orientations, 230 | angle_epsilon=angle_epsilon) 231 | 232 | return primary_orientations 233 | 234 | 235 | def regularize_segments(segments, primary_orientations, max_error=None): 236 | """ 237 | Sets the orientation of the segments to the closest of the given 238 | orientations. 239 | 240 | Parameters 241 | ---------- 242 | segments : list of BoundarySegment 243 | The wall segments to regularize. 244 | primary_orientations : list of floats 245 | The orientations all other orientations will be set to, given 246 | in radians. 247 | max_error : float or int, optional 248 | The maximum error a segment can have after regularization. If 249 | above the original orientation will be kept. 250 | 251 | Returns 252 | ------- 253 | segments : list of BoundarySegment 254 | The wall segments after regularization. 255 | """ 256 | for s in segments: 257 | target_orientation = s.target_orientation(primary_orientations) 258 | try: 259 | s.regularize(target_orientation, max_error=max_error) 260 | except utils.error.ThresholdError: 261 | pass 262 | 263 | return segments 264 | -------------------------------------------------------------------------------- /building_boundary/core/segment.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | @author: Chris Lucas 5 | """ 6 | 7 | import math 8 | import numpy as np 9 | 10 | from .. import utils 11 | 12 | 13 | def PCA(points): 14 | """ 15 | Does a Principle Component Analysis (PCA) for a set of 3D 16 | points (the structure tensor) by computing the eigenvalues 17 | and eigenvectors of the covariance matrix of a point cloud. 18 | 19 | Parameters 20 | ---------- 21 | points : (Mx3) array 22 | X, Y and Z coordinates of points. 23 | 24 | Returns 25 | ------- 26 | eigenvalues : (1x3) array 27 | The eigenvalues corrisponding to the eigenvectors of the covariance 28 | matrix. 29 | eigenvectors : (3x3) array 30 | The eigenvectors of the covariance matrix. 31 | """ 32 | cov_mat = np.cov(points, rowvar=False) 33 | eigenvalues, eigenvectors = np.linalg.eig(cov_mat) 34 | order = np.argsort(-eigenvalues) 35 | eigenvalues = eigenvalues[order] 36 | eigenvectors = eigenvectors[:, order] 37 | return eigenvalues, eigenvectors 38 | 39 | 40 | class BoundarySegment(object): 41 | def __init__(self, points): 42 | """ 43 | Initiate a boundary segment for a set of points. 44 | 45 | Parameters 46 | ---------- 47 | points : (Mx2) array 48 | X and Y coordinates of points. 49 | 50 | Attributes 51 | ---------- 52 | points : (Mx2) array 53 | X and Y coordinates of points. 54 | a : float 55 | The a coefficient (ax + by + c = 0) of the line. 56 | b : float 57 | The b coefficient (ax + by + c = 0) of the line. 58 | c : float 59 | The c coefficient (ax + by + c = 0) of the line. 60 | end_points : (2x2) array 61 | The coordinates of the end points of the line segment. 62 | length : float 63 | The length of the line segment. 64 | orientation : float 65 | The orientation of the line segment (in radians). 66 | """ 67 | self.points = points 68 | self.fit_line() 69 | 70 | def slope(self): 71 | return -self.a / self.b 72 | 73 | def intercept(self): 74 | return -self.c / self.b 75 | 76 | @property 77 | def line(self): 78 | return (self.a, self.b, self.c) 79 | 80 | @line.setter 81 | def line(self, line): 82 | self.a, self.b, self.c = line 83 | self._create_line_segment() 84 | 85 | def fit_line(self, max_error=None): 86 | """ 87 | Fit a line to the set of points of the object. 88 | 89 | Parameters 90 | ---------- 91 | max_error : float or int 92 | The maximum error (max distance points to line) the 93 | fitted line is allowed to have. A ThresholdError will be 94 | raised if this max error is exceeded. 95 | 96 | Raises 97 | ------ 98 | ThresholdError 99 | If the error of the fitted line (max distance points to 100 | line) exceeds the given max error. 101 | """ 102 | if len(self.points) == 1: 103 | raise ValueError('Not enough points to fit a line.') 104 | elif len(self.points) == 2: 105 | dx, dy = np.diff(self.points, axis=0)[0] 106 | if dx == 0: 107 | self.a = 0 108 | else: 109 | self.a = dy / dx 110 | self.b = -1 111 | self.c = (np.mean(self.points[:, 1]) - 112 | np.mean(self.points[:, 0]) * self.a) 113 | elif all(self.points[0, 0] == self.points[:, 0]): 114 | self.a = 1 115 | self.b = 0 116 | self.c = -self.points[0, 0] 117 | elif all(self.points[0, 1] == self.points[:, 1]): 118 | self.a = 0 119 | self.b = 1 120 | self.c = -self.points[0, 1] 121 | else: 122 | _, eigenvectors = PCA(self.points) 123 | self.a = eigenvectors[1, 0] / eigenvectors[0, 0] 124 | self.b = -1 125 | self.c = (np.mean(self.points[:, 1]) - 126 | np.mean(self.points[:, 0]) * self.a) 127 | 128 | if max_error is not None: 129 | error = self.error() 130 | if error > max_error: 131 | raise utils.error.ThresholdError( 132 | "Could not fit a proper line. Error: {}".format(error) 133 | ) 134 | 135 | self._create_line_segment() 136 | 137 | def _point_on_line(self, point): 138 | """ 139 | Finds the closest point on the fitted line from another point. 140 | 141 | Parameters 142 | ---------- 143 | point : (1x2) array 144 | The X and Y coordinates of a point. 145 | 146 | Returns 147 | ------- 148 | point : (1x2) array 149 | The X and Y coordinates of the closest point to the given 150 | point on the fitted line. 151 | 152 | .. [1] https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line#Line_defined_by_an_equation # noqa 153 | """ 154 | if self.a == 0 and self.b == 0: 155 | raise ValueError('Invalid line. Line coefficients a and b ' 156 | '(ax + by + c = 0) cannot both be zero.') 157 | 158 | x = (self.b * (self.b * point[0] - self.a * point[1]) - 159 | self.a * self.c) / (self.a**2 + self.b**2) 160 | y = (self.a * (-self.b * point[0] + self.a * point[1]) - 161 | self.b * self.c) / (self.a**2 + self.b**2) 162 | return [x, y] 163 | 164 | def _create_line_segment(self): 165 | """ 166 | Defines a line segment of the fitted line by creating 167 | the end points, length and orientation. 168 | 169 | Raises 170 | ------ 171 | ValueError 172 | If not enough points exist to create a line segment. 173 | """ 174 | if len(self.points) == 1: 175 | raise ValueError('Not enough points to create a line.') 176 | else: 177 | start_point = self._point_on_line(self.points[0]) 178 | end_point = self._point_on_line(self.points[-1]) 179 | self.end_points = np.array([start_point, end_point]) 180 | dx, dy = np.diff(self.end_points, axis=0)[0] 181 | self.length = math.hypot(dx, dy) 182 | self.orientation = math.atan2(dy, dx) 183 | 184 | def error(self): 185 | """ 186 | Computes the max distance between the points and the fitted 187 | line. 188 | 189 | Returns 190 | ------- 191 | error : float 192 | The max distance between the points and the fitted line. 193 | """ 194 | self.dist_points_line() 195 | 196 | return max(abs(self.distances)) 197 | 198 | def dist_points_line(self): 199 | """ 200 | Computes the distances from each point to the fitted line. 201 | 202 | Attributes 203 | ---------- 204 | distances : (1xN) array 205 | The distances from each point to the fitted line. 206 | 207 | .. [1] https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line 208 | """ 209 | self.distances = (abs(self.a * self.points[:, 0] + 210 | self.b * self.points[:, 1] + self.c) / 211 | math.sqrt(self.a**2 + self.b**2)) 212 | 213 | def dist_point_line(self, point): 214 | """ 215 | Computes the distance from the given point to the fitted line. 216 | 217 | Parameters 218 | ---------- 219 | point : (1x2) array 220 | The X and Y coordinates of a point. 221 | 222 | Returns 223 | ------- 224 | dist : float 225 | The distance from the given point to the fitted line. 226 | 227 | .. [1] https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line 228 | """ 229 | dist = (abs(self.a * point[0] + self.b * point[1] + self.c) / 230 | math.sqrt(self.a**2 + self.b**2)) 231 | return dist 232 | 233 | def target_orientation(self, primary_orientations): 234 | """ 235 | Determines the which of the given primary orientations is closest 236 | to the orientation of this line segment. 237 | 238 | Parameters 239 | ---------- 240 | primary_orientations : list of float 241 | The determined primary orientations. 242 | 243 | Returns 244 | ------- 245 | orientation : float 246 | The primary orientation closest to the orientation of this line 247 | segment. 248 | """ 249 | po_diff = [utils.angle.min_angle_difference(self.orientation, o) for 250 | o in primary_orientations] 251 | min_po_diff = min(po_diff) 252 | return primary_orientations[po_diff.index(min_po_diff)] 253 | 254 | def regularize(self, orientation, max_error=None): 255 | """ 256 | Recreates the line segment based on the given orientation. 257 | 258 | Parameters 259 | ---------- 260 | orientation : float or int 261 | The orientation the line segment should have. In radians from 262 | 0 to pi (east to west counterclockwise) and 263 | 0 to -pi (east to west clockwise). 264 | max_error : float or int 265 | The maximum error (max distance points to line) the 266 | fitted line is allowed to have. A ThresholdError will be 267 | raised if this max error is exceeded. 268 | 269 | Raises 270 | ------ 271 | ThresholdError 272 | If the error of the fitted line (max distance points to 273 | line) exceeds the given max error. 274 | 275 | .. [1] https://math.stackexchange.com/questions/1377716/how-to-find-a-least-squares-line-with-a-known-slope # noqa 276 | """ 277 | prev_a = self.a 278 | prev_b = self.b 279 | prev_c = self.c 280 | 281 | if not np.isclose(orientation, self.orientation): 282 | if np.isclose(abs(orientation), math.pi / 2): 283 | self.a = 1 284 | self.b = 0 285 | self.c = np.mean(self.points[:, 0]) 286 | elif (np.isclose(abs(orientation), math.pi) or 287 | np.isclose(orientation, 0)): 288 | self.a = 0 289 | self.b = 1 290 | self.c = np.mean(self.points[:, 1]) 291 | else: 292 | self.a = math.tan(orientation) 293 | self.b = -1 294 | self.c = (sum(self.points[:, 1] - self.a * self.points[:, 0]) / 295 | len(self.points)) 296 | 297 | if max_error is not None: 298 | error = self.error() 299 | if error > max_error: 300 | self.a = prev_a 301 | self.b = prev_b 302 | self.c = prev_c 303 | raise utils.error.ThresholdError( 304 | "Could not fit a proper line. Error: {}".format(error) 305 | ) 306 | 307 | self._create_line_segment() 308 | 309 | def line_intersect(self, line): 310 | """ 311 | Compute the intersection between this line and another. 312 | 313 | Parameters 314 | ---------- 315 | line : (1x3) array-like 316 | The a, b, and c coefficients (ax + by + c = 0) of a line. 317 | 318 | Returns 319 | ------- 320 | point : (1x2) array 321 | The coordinates of intersection. Returns empty array if no 322 | intersection found. 323 | """ 324 | a, b, c = line 325 | d = self.a * b - self.b * a 326 | if d != 0: 327 | dx = -self.c * b + self.b * c 328 | dy = self.c * a - self.a * c 329 | x = dx / float(d) 330 | y = dy / float(d) 331 | return np.array([x, y]) 332 | else: 333 | return np.array([]) 334 | 335 | def side_point_on_line(self, point): 336 | """ 337 | Determines on which side of the line segment a point, which is on the 338 | line, lies. 339 | 340 | Parameters 341 | ---------- 342 | point : (1x2) array 343 | The X and Y coordinates of a point, which lies on the line of this 344 | segment. 345 | 346 | Returns 347 | ------- 348 | : int 349 | 0 if point on the line segment 350 | 1 if point on the side of the starting point 351 | -1 if point on the side of the end point 352 | """ 353 | a = self.end_points[0] 354 | b = self.end_points[1] 355 | c = point 356 | if not np.isclose(np.cross(b-a, c-a), 0): 357 | raise ValueError('Given point not on line.') 358 | dist_ab = utils.geometry.distance(a, b) 359 | dist_ac = utils.geometry.distance(a, c) 360 | dist_bc = utils.geometry.distance(b, c) 361 | if np.isclose(dist_ac + dist_bc, dist_ab): 362 | return 0 363 | elif dist_ac < dist_bc: 364 | return 1 365 | elif dist_bc < dist_ac: 366 | return -1 367 | -------------------------------------------------------------------------------- /building_boundary/core/segmentation.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | @author: Chris Lucas 5 | """ 6 | 7 | import numpy as np 8 | from skimage.measure import LineModelND, ransac 9 | from .segment import BoundarySegment 10 | 11 | 12 | def ransac_line_segmentation(points, distance): 13 | """ 14 | Segment a line using RANSAC. 15 | 16 | Parameters 17 | ---------- 18 | points : (Mx2) array 19 | The coordinates of the points 20 | distance : float 21 | The maximum distance between a point and a line for a point to be 22 | considered belonging to that line. 23 | 24 | Returns 25 | ------- 26 | inliers : list of bool 27 | True where point is an inlier. 28 | """ 29 | _, inliers = ransac(points, LineModelND, 30 | min_samples=2, 31 | residual_threshold=distance, 32 | max_trials=1000) 33 | return inliers 34 | 35 | 36 | def extend_segment(segment, points, indices, distance): 37 | """ 38 | Extend a line found by ransac based on the sequence and the distance. 39 | 40 | Parameters 41 | ---------- 42 | segment : list of int 43 | The indices of the points belonging to the segment/line. 44 | points : (Mx2) array 45 | The coordinates of all the points. 46 | indices : list of int 47 | The indices of the points in the sequence. 48 | distance : float 49 | The maximum distance between a point and a line for a point to be 50 | considered belonging to that line. 51 | 52 | Returns 53 | ------- 54 | segment : list of int 55 | The indices of the points belonging to the segment/line. 56 | """ 57 | n_points = len(points) 58 | line_segment = BoundarySegment(points[segment]) 59 | 60 | edge_case = indices[0] == 0 and indices[-1] == n_points-1 61 | 62 | i = segment[0] - 1 63 | while True: 64 | if edge_case and i < indices[0]: 65 | i = i % n_points 66 | elif i < indices[0]-1 or i < 0: 67 | break 68 | 69 | if line_segment.dist_point_line(points[i]) < distance: 70 | segment.insert(0, i) 71 | else: 72 | if i - 2 >= indices[0]: 73 | if not (line_segment.dist_point_line( 74 | points[i-1] 75 | ) < distance and 76 | line_segment.dist_point_line( 77 | points[i-2] 78 | ) < distance): 79 | break 80 | elif edge_case: 81 | if not (line_segment.dist_point_line( 82 | points[(i-1) % n_points] 83 | ) < distance and 84 | line_segment.dist_point_line( 85 | points[(i-2) % n_points] 86 | ) < distance): 87 | break 88 | i -= 1 89 | 90 | i = segment[-1] + 1 91 | while True: 92 | if edge_case and i > indices[-1]: 93 | i = i % n_points 94 | elif i > indices[-1]+1 or i >= n_points: 95 | break 96 | 97 | if line_segment.dist_point_line(points[i]) < distance: 98 | segment.append(i) 99 | else: 100 | if i + 2 <= indices[-1]: 101 | if not (line_segment.dist_point_line( 102 | points[i+1] 103 | ) < distance and 104 | line_segment.dist_point_line( 105 | points[i+2] 106 | ) < distance): 107 | break 108 | elif edge_case: 109 | if not (line_segment.dist_point_line( 110 | points[(i+1) % n_points] 111 | ) < distance and 112 | line_segment.dist_point_line( 113 | points[(i+2) % n_points] 114 | ) < distance): 115 | break 116 | i += 1 117 | 118 | return segment 119 | 120 | 121 | def extract_segment(points, indices, distance): 122 | """ 123 | Extract a line segment from a sequence of points. 124 | 125 | Parameters 126 | ---------- 127 | points : (Mx2) array 128 | The coordinates of all the points. 129 | indices : list of int 130 | The indices of the points in the sequence. 131 | distance : float 132 | The maximum distance between a point and a line for a point to be 133 | considered belonging to that line. 134 | 135 | Returns 136 | ------- 137 | segment : list of int 138 | The indices of the points belonging to the segment/line. 139 | """ 140 | inliers = ransac_line_segmentation(points[indices], distance) 141 | inliers = indices[inliers] 142 | 143 | sequences = np.split(inliers, np.where(np.diff(inliers) != 1)[0] + 1) 144 | segment = list(max(sequences, key=len)) 145 | 146 | if len(segment) > 1: 147 | segment = extend_segment(segment, points, indices, distance) 148 | elif len(segment) == 1: 149 | if segment[0] + 1 in indices: 150 | segment.append(segment[0] + 1) 151 | segment = extend_segment(segment, points, indices, distance) 152 | elif segment[0] - 1 in indices: 153 | segment.insert(0, segment[0] - 1) 154 | segment = extend_segment(segment, points, indices, distance) 155 | 156 | return segment 157 | 158 | 159 | def get_insert_loc(segments, segment): 160 | """ 161 | Uses a binary search to find the correct location to insert a new segment. 162 | 163 | Parameters 164 | ---------- 165 | segments : list of list of int 166 | The indices of the points belonging to the segments/lines. 167 | segment : list of int 168 | The indices of the points belonging to the segment/line. 169 | 170 | Returns 171 | ------- 172 | : int 173 | The index where the segment should be inserted. 174 | """ 175 | if len(segments) == 0: 176 | return 0 177 | if segment[0] > segments[-1][0]: 178 | return len(segments) 179 | 180 | lo = 0 181 | hi = len(segments) 182 | while lo < hi: 183 | mid = (lo + hi) // 2 184 | if segment[0] < segments[mid][0]: 185 | hi = mid 186 | else: 187 | lo = mid + 1 188 | return lo 189 | 190 | 191 | def get_remaining_sequences(indices, mask): 192 | """ 193 | Gets the remaining sequences given the points that are already part of 194 | a segment. 195 | 196 | Parameters 197 | ---------- 198 | indices : list of int 199 | The indices of the points in the sequence. 200 | mask : list of bool 201 | Marks the points that are part of a segment. 202 | 203 | Returns 204 | ------- 205 | sequences : list of list of int 206 | The indices of each remaining sequence. 207 | """ 208 | sequences = np.split(indices, np.where(np.diff(mask) == 1)[0] + 1) 209 | 210 | if mask[0]: 211 | sequences = [s for i, s in enumerate(sequences) if i % 2 == 0] 212 | else: 213 | sequences = [s for i, s in enumerate(sequences) if i % 2 != 0] 214 | 215 | sequences = [s for s in sequences if len(s) > 1] 216 | 217 | return sequences 218 | 219 | 220 | def extract_segments(segments, points, indices, mask, distance): 221 | """ 222 | Extract line segments from a ring of points. 223 | 224 | Note: This is a recurisve function. Initiate with an empty segments 225 | list. 226 | 227 | Parameters 228 | ---------- 229 | segments : list of list of int 230 | The indices of the points belonging to segments/lines. 231 | points : (Mx2) array 232 | The coordinates of all the points. 233 | indices : list of int 234 | The indices of the points in the sequence. 235 | mask : list of bool 236 | Marks the points that are part of a segment. 237 | distance : float 238 | The maximum distance between a point and a line for a point to be 239 | considered belonging to that line. 240 | """ 241 | if len(indices) == 2: 242 | segment = list(indices) 243 | segment = extend_segment(segment, points, indices, distance) 244 | else: 245 | segment = extract_segment(points, indices, distance) 246 | 247 | if len(segment) > 2: 248 | insert_loc = get_insert_loc(segments, segment) 249 | segments.insert(insert_loc, segment) 250 | 251 | mask[segment] = False 252 | 253 | sequences = get_remaining_sequences(indices, mask[indices]) 254 | 255 | for s in sequences: 256 | extract_segments(segments, points, s, mask, distance) 257 | 258 | 259 | def boundary_segmentation(points, distance): 260 | """ 261 | Extract linear segments using RANSAC. 262 | 263 | Parameters 264 | ---------- 265 | points : (Mx2) array 266 | The coordinates of the points. 267 | distance : float 268 | The maximum distance between a point and a line for a point to be 269 | considered belonging to that line. 270 | 271 | Returns 272 | ------- 273 | segments : list of array 274 | The linear segments. 275 | """ 276 | points_shifted = points.copy() 277 | shift = np.min(points_shifted, axis=0) 278 | points_shifted -= shift 279 | 280 | mask = np.ones(len(points_shifted), dtype=np.bool) 281 | indices = np.arange(len(points_shifted)) 282 | 283 | segments = [] 284 | extract_segments(segments, points_shifted, indices, mask, distance) 285 | 286 | segments = [points_shifted[i]+shift for i in segments] 287 | 288 | return segments 289 | -------------------------------------------------------------------------------- /building_boundary/footprint.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | @author: Chris Lucas 5 | """ 6 | 7 | import math 8 | 9 | import numpy as np 10 | from shapely.geometry import ( 11 | Polygon, MultiPolygon, LineString, MultiLineString, LinearRing 12 | ) 13 | from shapely import wkt 14 | 15 | from building_boundary import utils 16 | 17 | 18 | def line_orientations(lines): 19 | """ 20 | Computes the orientations of the lines. 21 | 22 | Parameters 23 | ---------- 24 | lines : list of (2x2) array 25 | The lines defined by the coordinates two points. 26 | 27 | Returns 28 | ------- 29 | orientations : list of float 30 | The orientations of the lines in radians from 31 | 0 to pi (east to west counterclockwise) 32 | 0 to -pi (east to west clockwise) 33 | """ 34 | orientations = [] 35 | for l in lines: 36 | dx, dy = l[0] - l[1] 37 | orientation = math.atan2(dy, dx) 38 | if not any([np.isclose(orientation, o) for o in orientations]): 39 | orientations.append(orientation) 40 | return orientations 41 | 42 | 43 | def geometry_orientations(geom): 44 | """ 45 | Computes the orientations of the lines of a geometry (Polygon, 46 | MultiPolygon, LineString, MultiLineString, or LinearRing). 47 | 48 | Parameters 49 | ---------- 50 | geom : Polygon, MultiPolygon, LineString, MultiLineString, or LinearRing 51 | The geometry 52 | 53 | Returns 54 | ------- 55 | orientations : list of float 56 | The orientations of the lines of the geometry in radians from 57 | 0 to pi (east to west counterclockwise) 58 | 0 to -pi (east to west clockwise) 59 | """ 60 | orientations = [] 61 | if type(geom) == Polygon: 62 | lines = utils.create_pairs(geom.exterior.coords[:-1]) 63 | orientations = line_orientations(lines) 64 | elif type(geom) == MultiPolygon: 65 | for p in geom: 66 | lines = utils.create_pairs(p.exterior.coords[:-1]) 67 | orientations.extend(line_orientations(lines)) 68 | elif type(geom) == LineString: 69 | if geom.coords[0] == geom.coords[-1]: 70 | lines = utils.create_pairs(geom.coords[:-1]) 71 | else: 72 | lines = list(utils.create_pairs(geom.coords))[:-1] 73 | orientations = line_orientations(lines) 74 | elif type(geom) == MultiLineString: 75 | for l in geom: 76 | if l.coords[0] == l.coords[-1]: 77 | lines = utils.create_pairs(l.coords[:-1]) 78 | else: 79 | lines = list(utils.create_pairs(l.coords))[:-1] 80 | orientations.extend(line_orientations(lines)) 81 | elif type(geom) == LinearRing: 82 | lines = utils.create_pairs(geom.coords[:-1]) 83 | orientations = line_orientations(lines) 84 | else: 85 | raise TypeError('Invalid geometry type. Expects Polygon, ' 86 | 'MultiPolygon, LineString, MultiLineString, ' 87 | 'or LinearRing.') 88 | return orientations 89 | 90 | 91 | def compute_orientations(footprint_wkt): 92 | """ 93 | Computes the orientations of the footprint. 94 | 95 | Parameters 96 | ---------- 97 | footprint_wkt : string 98 | The footprint geometry defined by a WKT string. 99 | 100 | Returns 101 | ------- 102 | orientations : list of float 103 | The orientations of the lines of the geometry in radians from 104 | 0 to pi (east to west counterclockwise) 105 | 0 to -pi (east to west clockwise) 106 | """ 107 | footprint_geom = wkt.loads(footprint_wkt) 108 | orientations = geometry_orientations(footprint_geom) 109 | return orientations 110 | -------------------------------------------------------------------------------- /building_boundary/shapes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geodan/building-boundary/d0eb88d99743af917568131e8609f481b10e4520/building_boundary/shapes/__init__.py -------------------------------------------------------------------------------- /building_boundary/shapes/alpha_shape.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | @author: Chris Lucas 5 | """ 6 | 7 | import math 8 | 9 | import numpy as np 10 | from shapely.geometry import Polygon, MultiPolygon 11 | 12 | try: 13 | from CGAL.CGAL_Kernel import Point_2 14 | from CGAL.CGAL_Alpha_shape_2 import Alpha_shape_2 15 | from CGAL.CGAL_Alpha_shape_2 import REGULAR 16 | CGAL_AVAILABLE = True 17 | cascaded_union = None 18 | Delaunay = None 19 | except ImportError: 20 | from shapely.ops import cascaded_union 21 | from scipy.spatial import Delaunay 22 | CGAL_AVAILABLE = False 23 | Point_2 = None 24 | Alpha_shape_2 = None 25 | REGULAR = None 26 | 27 | 28 | def alpha_shape_cgal(points, alpha): 29 | """ 30 | Uses CGAL to compute the alpha shape (a concave hull) of a set of points. 31 | The alpha shape will not contain any interiors. 32 | 33 | Parameters 34 | ---------- 35 | points : (Mx2) array 36 | The x and y coordinates of the points 37 | alpha : float 38 | Influences the shape of the alpha shape. Higher values lead to more 39 | triangles being deleted. 40 | 41 | Returns 42 | ------- 43 | alpha_shape : polygon 44 | The computed alpha shape as a shapely polygon 45 | """ 46 | points_cgal = [Point_2(*p) for p in points] 47 | 48 | as2 = Alpha_shape_2(points_cgal, 0, REGULAR) 49 | as2.set_alpha(alpha) 50 | 51 | edges = [] 52 | for e in as2.alpha_shape_edges(): 53 | segment = as2.segment(e) 54 | edges.append([[segment.vertex(0).x(), segment.vertex(0).y()], 55 | [segment.vertex(1).x(), segment.vertex(1).y()]]) 56 | edges = np.array(edges) 57 | 58 | e1s = edges[:, 0].tolist() 59 | e2s = edges[:, 1].tolist() 60 | polygons = [] 61 | 62 | while len(e1s) > 0: 63 | polygon = [] 64 | current_point = e2s[0] 65 | polygon.append(current_point) 66 | del e1s[0] 67 | del e2s[0] 68 | 69 | while True: 70 | try: 71 | i = e1s.index(current_point) 72 | except ValueError: 73 | break 74 | 75 | current_point = e2s[i] 76 | polygon.append(current_point) 77 | del e1s[i] 78 | del e2s[i] 79 | 80 | polygons.append(polygon) 81 | 82 | polygons = [Polygon(p) for p in polygons if len(p) > 2] 83 | 84 | alpha_shape = MultiPolygon(polygons).buffer(0) 85 | 86 | return alpha_shape 87 | 88 | 89 | def triangle_geometry(triangle): 90 | """ 91 | Compute the area and circumradius of a triangle. 92 | 93 | Parameters 94 | ---------- 95 | triangle : (3x3) array-like 96 | The coordinates of the points which form the triangle. 97 | 98 | Returns 99 | ------- 100 | area : float 101 | The area of the triangle 102 | circum_r : float 103 | The circumradius of the triangle 104 | """ 105 | pa, pb, pc = triangle 106 | # Lengths of sides of triangle 107 | a = math.hypot((pa[0] - pb[0]), (pa[1] - pb[1])) 108 | b = math.hypot((pb[0] - pc[0]), (pb[1] - pc[1])) 109 | c = math.hypot((pc[0] - pa[0]), (pc[1] - pa[1])) 110 | # Semiperimeter of triangle 111 | s = (a + b + c) / 2.0 112 | # Area of triangle by Heron's formula 113 | area = math.sqrt(s * (s - a) * (s - b) * (s - c)) 114 | if area != 0: 115 | circum_r = (a * b * c) / (4.0 * area) 116 | else: 117 | circum_r = 0 118 | return area, circum_r 119 | 120 | 121 | def alpha_shape_python(points, alpha): 122 | """ 123 | Compute the alpha shape (or concave hull) of points. 124 | The alpha shape will not contain any interiors. 125 | 126 | Parameters 127 | ---------- 128 | points : (Mx2) array 129 | The coordinates of the points. 130 | alpha : float 131 | Influences the shape of the alpha shape. Higher values lead to more 132 | triangles being deleted. 133 | 134 | Returns 135 | ------- 136 | alpha_shape : polygon 137 | The computed alpha shape as a shapely polygon 138 | """ 139 | triangles = [] 140 | tri = Delaunay(points) 141 | for t in tri.simplices: 142 | area, circum_r = triangle_geometry(points[t]) 143 | if area != 0: 144 | if circum_r < 1.0 / alpha: 145 | triangles.append(Polygon(points[t])) 146 | 147 | alpha_shape = cascaded_union(triangles) 148 | if type(alpha_shape) == MultiPolygon: 149 | alpha_shape = MultiPolygon([Polygon(s.exterior) for s in alpha_shape]) 150 | else: 151 | alpha_shape = Polygon(alpha_shape.exterior) 152 | 153 | return alpha_shape 154 | 155 | 156 | def compute_alpha_shape(points, alpha): 157 | """ 158 | Compute the alpha shape (or concave hull) of points. 159 | The alpha shape will not contain any interiors. 160 | 161 | Parameters 162 | ---------- 163 | points : (Mx2) array 164 | The coordinates of the points. 165 | alpha : float 166 | Influences the shape of the alpha shape. Higher values lead to more 167 | triangles being deleted. 168 | 169 | Returns 170 | ------- 171 | alpha_shape : polygon 172 | The computed alpha shape as a shapely polygon 173 | """ 174 | if len(points) < 4: 175 | raise ValueError('Not enough points to compute an alpha shape.') 176 | if CGAL_AVAILABLE: 177 | alpha_shape = alpha_shape_cgal(points, alpha) 178 | else: 179 | alpha_shape = alpha_shape_python(points, alpha) 180 | return alpha_shape 181 | -------------------------------------------------------------------------------- /building_boundary/shapes/bounding_box.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | @author: Chris Lucas 5 | """ 6 | 7 | import math 8 | import numpy as np 9 | from scipy.spatial import ConvexHull 10 | from shapely.geometry import Polygon, Point 11 | 12 | 13 | def compute_edge_angles(edges): 14 | """ 15 | Compute the angles between the edges and the x-axis. 16 | 17 | Parameters 18 | ---------- 19 | edges : (Mx2x2) array 20 | The coordinates of the sets of points that define the edges. 21 | 22 | Returns 23 | ------- 24 | edge_angles : (Mx1) array 25 | The angles between the edges and the x-axis. 26 | """ 27 | edges_count = len(edges) 28 | edge_angles = np.zeros(edges_count) 29 | for i in range(edges_count): 30 | edge_x = edges[i][1][0] - edges[i][0][0] 31 | edge_y = edges[i][1][1] - edges[i][0][1] 32 | edge_angles[i] = math.atan2(edge_y, edge_x) 33 | 34 | return np.unique(edge_angles) 35 | 36 | 37 | def rotate_points(points, angle): 38 | """ 39 | Rotate points in a coordinate system using a rotation matrix based on 40 | an angle. 41 | 42 | Parameters 43 | ---------- 44 | points : (Mx2) array 45 | The coordinates of the points. 46 | angle : float 47 | The angle by which the points will be rotated (in radians). 48 | 49 | Returns 50 | ------- 51 | points_rotated : (Mx2) array 52 | The coordinates of the rotated points. 53 | """ 54 | # Compute rotation matrix 55 | rot_matrix = np.array(((math.cos(angle), -math.sin(angle)), 56 | (math.sin(angle), math.cos(angle)))) 57 | # Apply rotation matrix to the points 58 | points_rotated = np.dot(points, rot_matrix) 59 | 60 | return np.array(points_rotated) 61 | 62 | 63 | def rotating_calipers_bbox(points, angles): 64 | """ 65 | Compute the oriented minimum bounding box using a rotating calipers 66 | algorithm. 67 | 68 | Parameters 69 | ---------- 70 | points : (Mx2) array 71 | The coordinates of the points of the convex hull. 72 | angles : (Mx1) array-like 73 | The angles the edges of the convex hull and the x-axis. 74 | 75 | Returns 76 | ------- 77 | corner_points : (4x2) array 78 | The coordinates of the corner points of the minimum oriented 79 | bounding box. 80 | """ 81 | min_bbox = {'angle': 0, 82 | 'minmax': (0, 0, 0, 0), 83 | 'area': float('inf')} 84 | 85 | for a in angles: 86 | # Rotate the points and compute the new bounding box 87 | rotated_points = rotate_points(points, a) 88 | min_x = min(rotated_points[:, 0]) 89 | max_x = max(rotated_points[:, 0]) 90 | min_y = min(rotated_points[:, 1]) 91 | max_y = max(rotated_points[:, 1]) 92 | area = (max_x - min_x) * (max_y - min_y) 93 | 94 | # Save if the new bounding box is smaller than the current smallest 95 | if area < min_bbox['area']: 96 | min_bbox = {'angle': a, 97 | 'minmax': (min_x, max_x, min_y, max_y), 98 | 'area': area} 99 | 100 | # Extract the rotated corner points of the minimum bounding box 101 | c1 = (min_bbox['minmax'][0], min_bbox['minmax'][2]) 102 | c2 = (min_bbox['minmax'][0], min_bbox['minmax'][3]) 103 | c3 = (min_bbox['minmax'][1], min_bbox['minmax'][3]) 104 | c4 = (min_bbox['minmax'][1], min_bbox['minmax'][2]) 105 | rotated_corner_points = [c1, c2, c3, c4] 106 | 107 | # Rotate the corner points back to the original system 108 | corner_points = np.array(rotate_points(rotated_corner_points, 109 | 2*math.pi-min_bbox['angle'])) 110 | 111 | return corner_points 112 | 113 | 114 | def check_error(points, bbox, max_error): 115 | """ 116 | Checks if the given bounding box is close enough to the points based on 117 | the given max error. 118 | 119 | Parameters 120 | ---------- 121 | points : (Mx2) array 122 | The coordinates of the points. 123 | bbox : (4x2) array 124 | The coordinates of the vertices of the bounding box. 125 | max_error : float 126 | The maximum error (distance) a point can have to the bounding box. 127 | 128 | Returns 129 | ------- 130 | : bool 131 | If all points are within the max error distance of the bounding box. 132 | """ 133 | distances = [bbox.exterior.distance(Point(p)) for p in points] 134 | return all([d < max_error for d in distances]) 135 | 136 | 137 | def compute_bounding_box(points, convex_hull=None, 138 | given_angles=None, max_error=None): 139 | """ 140 | Computes the minimum area oriented bounding box of a set of points. 141 | 142 | Parameters 143 | ---------- 144 | points : (Mx2) array 145 | The coordinates of the points. 146 | convex_hull : scipy.spatial.ConvexHull, optional 147 | The convex hull of the points, as computed by SciPy. 148 | given_angles : list of float, optional 149 | If set the minimum area bounding box of these angles will be checked 150 | (instead of the angles of all edges of the convex hull). 151 | max_error : float, optional 152 | The maximum error (distance) a point can have to the bounding box. 153 | 154 | Returns 155 | ------- 156 | bbox : polygon 157 | The minimum area oriented bounding box as a shapely polygon. 158 | """ 159 | if convex_hull is None: 160 | convex_hull = ConvexHull(points) 161 | hull_points = points[convex_hull.vertices] 162 | 163 | if given_angles is None: 164 | angles = compute_edge_angles(points[convex_hull.simplices]) 165 | else: 166 | angles = given_angles 167 | 168 | bbox_corner_points = rotating_calipers_bbox(hull_points, angles) 169 | bbox = Polygon(bbox_corner_points) 170 | 171 | if max_error is not None and given_angles is not None: 172 | if not check_error(points, bbox, max_error): 173 | angles = compute_edge_angles(points[convex_hull.simplices]) 174 | bbox_corner_points = rotating_calipers_bbox(hull_points, angles) 175 | bbox = Polygon(bbox_corner_points) 176 | 177 | return bbox 178 | -------------------------------------------------------------------------------- /building_boundary/shapes/bounding_triangle.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | @author: Chris Lucas 5 | """ 6 | 7 | from scipy.spatial import ConvexHull 8 | from shapely.geometry import Polygon 9 | import pymintriangle 10 | 11 | 12 | def compute_bounding_triangle(points, convex_hull=None): 13 | """ 14 | Computes the minimum area enclosing triangle around a set of 15 | 2D points. 16 | 17 | Parameters 18 | ---------- 19 | points : (Mx2) array 20 | The coordinates of the points. 21 | convex_hull : scipy.spatial.ConvexHull, optional 22 | The convex hull of the points, as computed by SciPy. 23 | 24 | Returns 25 | ------- 26 | triangle : polygon 27 | The minimum area enclosing triangle as a shapely polygon. 28 | """ 29 | if convex_hull is None: 30 | convex_hull = ConvexHull(points) 31 | triangle = pymintriangle.compute(points[convex_hull.vertices]) 32 | return Polygon(triangle) 33 | -------------------------------------------------------------------------------- /building_boundary/shapes/fit.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | @author: Chris Lucas 5 | """ 6 | 7 | import numpy as np 8 | from scipy.spatial import ConvexHull 9 | from shapely.geometry import Polygon 10 | from shapely.ops import cascaded_union 11 | 12 | import concave_hull 13 | 14 | from .alpha_shape import compute_alpha_shape 15 | from .bounding_box import compute_bounding_box 16 | from .bounding_triangle import compute_bounding_triangle 17 | 18 | 19 | def compute_shape(points, alpha=None, k=None): 20 | """ 21 | Computes the shape of a set of points based on concave hulls. 22 | 23 | Parameters 24 | ---------- 25 | points : (Mx2) array 26 | The coordinates of the points. 27 | alpha : float 28 | Set to compute the shape using an alpha shape using this 29 | chosen alpha. If both alpha and k are set both methods will be used and 30 | the resulting shapes merged to find the boundary points. 31 | k : int 32 | Set to compute the shape using a knn based concave hull 33 | algorithm using this amount of nearest neighbors. If both alpha and k 34 | are set both methods will be used and the resulting shapes merged to 35 | find the boundary points. 36 | 37 | Returns 38 | ------- 39 | shape : polygon 40 | The computed shape of the points. 41 | """ 42 | if alpha is not None: 43 | shape = compute_alpha_shape(points, alpha) 44 | 45 | if k is not None: 46 | boundary_points = concave_hull.compute(points, k, True) 47 | shape_ch = Polygon(boundary_points).buffer(0) 48 | shape = cascaded_union([shape, shape_ch]) 49 | 50 | if type(shape) != Polygon: 51 | shape = max(shape, key=lambda s: s.area) 52 | 53 | elif k is not None: 54 | boundary_points = concave_hull.compute(points, k, True) 55 | shape = Polygon(boundary_points).buffer(0) 56 | else: 57 | raise ValueError('Either k or alpha needs to be set.') 58 | 59 | return shape 60 | 61 | 62 | def determine_non_fit_area(shape, basic_shape, max_error=None): 63 | """ 64 | Determines the area of the part of the basic shape that does not fit the 65 | given shape (i.e. what is left after differencing the two shapes, 66 | and optionally negative buffering with the max error). 67 | 68 | Parameters 69 | ---------- 70 | shape : polygon 71 | The shape of the points. 72 | basic_shape : polygon 73 | The shape of the rectangle or triangle 74 | max_error : float, optional 75 | The maximum error (distance) a point may have to the shape. 76 | 77 | Returns 78 | ------- 79 | area : float 80 | The area of the part of the basic shape that did not fit the 81 | given shape. 82 | """ 83 | diff = basic_shape - shape 84 | if max_error is not None: 85 | diff = diff.buffer(-max_error) 86 | print('non fit area: {}'.format(diff.area)) 87 | return diff.area 88 | 89 | 90 | def fit_basic_shape(shape, max_error=None, given_angles=None): 91 | """ 92 | Compares a shape to a rectangle and a triangle. If a max error is 93 | given it will return the shape and indicate that the basic shape fits 94 | well enough if that is the case. 95 | 96 | Parameters 97 | ---------- 98 | shape : polygon 99 | The shape of the points. 100 | max_error : float, optional 101 | The maximum error (distance) a point may have to the shape. 102 | given_angles : list of float, optional 103 | If set, during the computation of the minimum area bounding box, 104 | the minimum area bounding box of these angles will be checked 105 | (instead of the angles of all edges of the convex hull). 106 | 107 | Returns 108 | ------- 109 | basic_shape : polygon 110 | The polygon of the basic shape (rectangle or triangle) that fits the 111 | best. 112 | basic_shape_fits : bool 113 | If the found basic shape fits well enough within the given max error. 114 | """ 115 | convex_hull = ConvexHull(shape.exterior.coords) 116 | 117 | bounding_box = compute_bounding_box( 118 | np.array(shape.exterior.coords), 119 | convex_hull=convex_hull, 120 | given_angles=given_angles, 121 | max_error=max_error 122 | ) 123 | 124 | bbox_non_fit_area = determine_non_fit_area( 125 | shape, bounding_box, max_error=max_error 126 | ) 127 | if max_error is not None and bbox_non_fit_area <= 0: 128 | return bounding_box, True 129 | 130 | bounding_triangle = compute_bounding_triangle( 131 | np.array(shape.exterior.coords), 132 | convex_hull=convex_hull 133 | ) 134 | 135 | tri_non_fit_area = determine_non_fit_area( 136 | shape, bounding_triangle, max_error=max_error 137 | ) 138 | if max_error is not None and tri_non_fit_area <= 0: 139 | return bounding_triangle, True 140 | 141 | if bbox_non_fit_area < tri_non_fit_area: 142 | return bounding_box, False 143 | else: 144 | return bounding_triangle, False 145 | -------------------------------------------------------------------------------- /building_boundary/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | @author: Chris Lucas 5 | """ 6 | 7 | import numpy as np 8 | 9 | from . import angle 10 | from . import geometry 11 | from . import error 12 | 13 | 14 | def create_pairs(iterable): 15 | """ 16 | Creates pairs in sequence between the elements of the 17 | iterable. 18 | 19 | Parameters 20 | ---------- 21 | iterable : list 22 | The interable to create pairs of. 23 | 24 | Returns 25 | ------- 26 | pairs : zip 27 | Iterator which contain the pairs. 28 | 29 | Examples 30 | -------- 31 | >>> list(create_pairs([3, 5, 1, 9, 8])) 32 | [(3, 5), (5, 1), (1, 9), (9, 8), (8, 3)] 33 | """ 34 | return zip(np.array(iterable), np.roll(iterable, -1, axis=0)) 35 | 36 | 37 | __all__ = [ 38 | 'angle', 39 | 'geometry', 40 | 'error', 41 | 'create_pairs' 42 | ] 43 | -------------------------------------------------------------------------------- /building_boundary/utils/angle.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | @author: Chris Lucas 5 | """ 6 | 7 | import math 8 | 9 | 10 | def min_angle_difference(a1, a2): 11 | """ 12 | Returns the minimal angle difference between two orientations. 13 | 14 | Parameters 15 | ---------- 16 | a1 : float 17 | An angle in radians 18 | a2 : float 19 | Another angle in radians 20 | 21 | Returns 22 | ------- 23 | angle : float 24 | The minimal angle difference in radians 25 | """ 26 | pos1 = abs(math.pi - abs(abs(a1 - a2) - math.pi)) 27 | if a1 < math.pi: 28 | pos2 = abs(math.pi - abs(abs((a1 + math.pi) - a2) - math.pi)) 29 | elif a2 < math.pi: 30 | pos2 = abs(math.pi - abs(abs(a1 - (a2 + math.pi)) - math.pi)) 31 | else: 32 | return pos1 33 | return pos1 if pos1 < pos2 else pos2 34 | 35 | 36 | def angle_difference(a1, a2): 37 | """ 38 | Returns the angle difference between two orientations. 39 | 40 | Parameters 41 | ---------- 42 | a1 : float 43 | An angle in radians 44 | a2 : float 45 | Another angle in radians 46 | 47 | Returns 48 | ------- 49 | angle : float 50 | The angle difference in radians 51 | """ 52 | return math.pi - abs(math.pi - abs(a1 - a2) % (math.pi*2)) 53 | 54 | 55 | def to_positive_angle(angle): 56 | """ 57 | Converts an angle to positive. 58 | 59 | Parameters 60 | ---------- 61 | angle : float 62 | The angle in radians 63 | 64 | Returns 65 | ------- 66 | angle : float 67 | The positive angle 68 | """ 69 | angle = angle % math.pi 70 | if angle < 0: 71 | angle += math.pi 72 | return angle 73 | 74 | 75 | def perpendicular(angle): 76 | """ 77 | Returns the perpendicular angle to the given angle. 78 | 79 | Parameters 80 | ---------- 81 | angle : float or int 82 | The given angle in radians 83 | 84 | Returns 85 | ------- 86 | perpendicular : float 87 | The perpendicular to the given angle in radians 88 | """ 89 | perp = angle + math.pi/2 90 | if perp > math.pi: 91 | perp = angle - math.pi/2 92 | return perp 93 | -------------------------------------------------------------------------------- /building_boundary/utils/error.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | @author: Chris Lucas 5 | """ 6 | 7 | 8 | class ThresholdError(Exception): 9 | def __init__(self, msg): 10 | self.msg = msg 11 | -------------------------------------------------------------------------------- /building_boundary/utils/geometry.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | @author: Chris Lucas 5 | """ 6 | 7 | import math 8 | 9 | 10 | def distance(p1, p2): 11 | """ 12 | The euclidean distance between two points. 13 | 14 | Parameters 15 | ---------- 16 | p1 : list or array 17 | A point in 2D space. 18 | p2 : list or array 19 | A point in 2D space. 20 | 21 | Returns 22 | ------- 23 | distance : float 24 | The euclidean distance between the two points. 25 | """ 26 | return math.hypot(*(p1-p2)) 27 | 28 | 29 | def perpedicular_line(line, p): 30 | """ 31 | Returns a perpendicular line to a line at a point. 32 | 33 | Parameters 34 | ---------- 35 | line : (1x3) array-like 36 | The a, b, and c coefficients (ax + by + c = 0) of a line. 37 | p : (1x2) array-like 38 | The coordinates of a point on the line. 39 | 40 | Returns 41 | ------- 42 | line : (1x3) array-like 43 | The a, b, and c coefficients (ax + by + c = 0) of the line 44 | perpendicular to the input line at point p. 45 | """ 46 | a, b, c = line 47 | pa = b 48 | pb = -a 49 | pc = -(p[0] * b - p[1] * a) 50 | return [pa, pb, pc] 51 | -------------------------------------------------------------------------------- /img/1_boundary_points.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geodan/building-boundary/d0eb88d99743af917568131e8609f481b10e4520/img/1_boundary_points.png -------------------------------------------------------------------------------- /img/2_segmentation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geodan/building-boundary/d0eb88d99743af917568131e8609f481b10e4520/img/2_segmentation.png -------------------------------------------------------------------------------- /img/3_regularization.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geodan/building-boundary/d0eb88d99743af917568131e8609f481b10e4520/img/3_regularization.png -------------------------------------------------------------------------------- /img/4_intersections.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geodan/building-boundary/d0eb88d99743af917568131e8609f481b10e4520/img/4_intersections.png -------------------------------------------------------------------------------- /img/5_result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geodan/building-boundary/d0eb88d99743af917568131e8609f481b10e4520/img/5_result.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | def read(filename): 5 | with open(filename) as f: 6 | return f.read() 7 | 8 | 9 | setup( 10 | name="building-boundary", 11 | version="0.4.0", 12 | author="Chris Lucas", 13 | author_email="chris.lucas@geodan.nl", 14 | description=( 15 | "A script to trace the boundary of a building (part) in a point cloud." 16 | ), 17 | license="MIT", 18 | keywords="building boundary trace point cloud", 19 | packages=find_packages(), 20 | long_description=read('README.rst'), 21 | classifiers=[ 22 | "Development Status :: 4 - Beta", 23 | "Topic :: Scientific/Engineering :: GIS", 24 | "License :: OSI Approved :: MIT License", 25 | ], 26 | install_requires=[ 27 | 'numpy', 28 | 'scipy', 29 | 'shapely', 30 | # 'cgal-bindings', 31 | 'scikit-image' 32 | ], 33 | zip_safe=False 34 | ) 35 | --------------------------------------------------------------------------------