├── .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 |
21 |
22 |
23 |
24 |
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 |
--------------------------------------------------------------------------------