@@ -3790,3 +3790,368 @@ def intensity_median(region, intensity_image):
3790
3790
gdf2 .sort_values ("label" , inplace = True )
3791
3791
df = gdf2
3792
3792
return da , df
3793
+
3794
+
3795
+ def orthogonalize (filepath , output = None , maxAngleChange = 15 , skewTolerance = 15 ):
3796
+ """Orthogonalizes polygon by making all angles 90 or 180 degrees. The source
3797
+ code is adapted from https://github.com/Mashin6/orthogonalize-polygon, which
3798
+ is distributed under the terms of the GNU General Public License v3.0.
3799
+ Credits to the original author Martin Machyna.
3800
+
3801
+ Args:
3802
+ filepath (str | geopandas.GeoDataFrame): The path to the input file or a GeoDataFrame.
3803
+ output (str, optional): The path to the output file. Defaults to None.
3804
+ maxAngleChange (int, optional): angle (0,45> degrees. Sets the maximum angle deviation
3805
+ from the cardinal direction for the segment to be still
3806
+ considered to continue in the same direction as the
3807
+ previous segment.
3808
+ skewTolerance (int, optional): angle <0,45> degrees. Sets skew tolerance for segments that
3809
+ are at 45˚±Tolerance angle from the overall rectangular shape
3810
+ of the polygon. Useful when preserving e.g. bay windows on a
3811
+ house.
3812
+ """
3813
+
3814
+ from shapely .geometry import Polygon
3815
+ from shapely .geometry import MultiPolygon
3816
+ import math
3817
+ import statistics
3818
+
3819
+ def calculate_initial_compass_bearing (pointA , pointB ):
3820
+ """
3821
+ Calculates the bearing between two points.
3822
+
3823
+ The formulae used is the following:
3824
+ θ = atan2(sin(Δlong).cos(lat2),
3825
+ cos(lat1).sin(lat2) − sin(lat1).cos(lat2).cos(Δlong))
3826
+
3827
+ :Parameters:
3828
+ - `pointA: The tuple representing the latitude/longitude for the
3829
+ first point. Latitude and longitude must be in decimal degrees
3830
+ - `pointB: The tuple representing the latitude/longitude for the
3831
+ second point. Latitude and longitude must be in decimal degrees
3832
+
3833
+ :Returns:
3834
+ The bearing in degrees
3835
+
3836
+ :Returns Type:
3837
+ float
3838
+ """
3839
+ if (type (pointA ) != tuple ) or (type (pointB ) != tuple ):
3840
+ raise TypeError ("Only tuples are supported as arguments" )
3841
+
3842
+ lat1 = math .radians (pointA [0 ])
3843
+ lat2 = math .radians (pointB [0 ])
3844
+
3845
+ diffLong = math .radians (pointB [1 ] - pointA [1 ])
3846
+
3847
+ x = math .sin (diffLong ) * math .cos (lat2 )
3848
+ y = math .cos (lat1 ) * math .sin (lat2 ) - (
3849
+ math .sin (lat1 ) * math .cos (lat2 ) * math .cos (diffLong )
3850
+ )
3851
+
3852
+ initial_bearing = math .atan2 (x , y )
3853
+
3854
+ # Now we have the initial bearing but math.atan2 return values
3855
+ # from -180° to + 180° which is not what we want for a compass bearing
3856
+ # The solution is to normalize the initial bearing as shown below
3857
+ initial_bearing = math .degrees (initial_bearing )
3858
+ compass_bearing = (initial_bearing + 360 ) % 360
3859
+
3860
+ return compass_bearing
3861
+
3862
+ def calculate_segment_angles (polySimple , maxAngleChange = 45 ):
3863
+ """
3864
+ Calculates angles of all polygon segments to cardinal directions.
3865
+
3866
+ :Parameters:
3867
+ - `polySimple: shapely polygon object containing simplified building.
3868
+ - `maxAngleChange: angle (0,45> degrees. Sets the maximum angle deviation
3869
+ from the cardinal direction for the segment to be still
3870
+ considered to continue in the same direction as the
3871
+ previous segment.
3872
+
3873
+ :Returns:
3874
+ - orgAngle: Segments bearing
3875
+ - corAngle: Segments angles to closest cardinal direction
3876
+ - dirAngle: Segments direction [N, E, S, W] as [0, 1, 2, 3]
3877
+
3878
+ :Returns Type:
3879
+ list
3880
+ """
3881
+ # Convert limit angle to angle for subtraction
3882
+ maxAngleChange = 45 - maxAngleChange
3883
+
3884
+ # Get points Lat/Lon
3885
+ simple_X = polySimple .exterior .xy [0 ]
3886
+ simple_Y = polySimple .exterior .xy [1 ]
3887
+
3888
+ # Calculate angle to cardinal directions for each segment of polygon
3889
+ orgAngle = [] # Original angles
3890
+ corAngle = [] # Correction angles used for rotation
3891
+ dirAngle = [] # 0,1,2,3 = N,E,S,W
3892
+ limit = [0 ] * 4
3893
+
3894
+ for i in range (0 , (len (simple_X ) - 1 )):
3895
+ point1 = (simple_Y [i ], simple_X [i ])
3896
+ point2 = (simple_Y [i + 1 ], simple_X [i + 1 ])
3897
+ angle = calculate_initial_compass_bearing (point1 , point2 )
3898
+
3899
+ if angle > (45 + limit [1 ]) and angle <= (135 - limit [1 ]):
3900
+ orgAngle .append (angle )
3901
+ corAngle .append (angle - 90 )
3902
+ dirAngle .append (1 )
3903
+
3904
+ elif angle > (135 + limit [2 ]) and angle <= (225 - limit [2 ]):
3905
+ orgAngle .append (angle )
3906
+ corAngle .append (angle - 180 )
3907
+ dirAngle .append (2 )
3908
+
3909
+ elif angle > (225 + limit [3 ]) and angle <= (315 - limit [3 ]):
3910
+ orgAngle .append (angle )
3911
+ corAngle .append (angle - 270 )
3912
+ dirAngle .append (3 )
3913
+
3914
+ elif angle > (315 + limit [0 ]) and angle <= 360 :
3915
+ orgAngle .append (angle )
3916
+ corAngle .append (angle - 360 )
3917
+ dirAngle .append (0 )
3918
+
3919
+ elif angle >= 0 and angle <= (45 - limit [0 ]):
3920
+ orgAngle .append (angle )
3921
+ corAngle .append (angle )
3922
+ dirAngle .append (0 )
3923
+
3924
+ limit = [0 ] * 4
3925
+ limit [dirAngle [i ]] = (
3926
+ maxAngleChange # Set angle limit for the current direction
3927
+ )
3928
+ limit [(dirAngle [i ] + 1 ) % 4 ] = (
3929
+ - maxAngleChange
3930
+ ) # Extend the angles for the adjacent directions
3931
+ limit [(dirAngle [i ] - 1 ) % 4 ] = - maxAngleChange
3932
+
3933
+ return orgAngle , corAngle , dirAngle
3934
+
3935
+ def rotate_polygon (polySimple , angle ):
3936
+ """Rotates polygon around its centroid for given angle."""
3937
+ if polySimple .is_empty or not polySimple .is_valid :
3938
+ return polySimple
3939
+
3940
+ try :
3941
+ # Create WGS84 referenced GeoSeries
3942
+ bS = gpd .GeoDataFrame (geometry = [polySimple ], crs = "EPSG:4326" )
3943
+
3944
+ # Temporary reproject to Mercator and rotate
3945
+ bSR = bS .to_crs ("epsg:3857" )
3946
+ if len (bSR ) == 0 :
3947
+ return polySimple
3948
+
3949
+ bSR = bSR .rotate (angle , origin = "centroid" , use_radians = False )
3950
+ bSR = bSR .to_crs ("epsg:4326" )
3951
+
3952
+ # Validate result before returning
3953
+ if len (bSR ) == 0 or bSR .geometry .is_empty .any ():
3954
+ return polySimple
3955
+
3956
+ return bSR .geometry .iloc [0 ]
3957
+
3958
+ except Exception as e :
3959
+ print (f"Rotation failed: { str (e )} " )
3960
+ return polySimple
3961
+
3962
+ def orthogonalize_polygon (polygon , maxAngleChange = 15 , skewTolerance = 15 ):
3963
+ """
3964
+ Master function that makes all angles in polygon outer and inner rings either 90 or 180 degrees.
3965
+ Idea adapted from JOSM function orthogonalize
3966
+ 1) Calculate bearing [0-360 deg] of each polygon segment
3967
+ 2) From bearing determine general direction [N, E, S ,W], then calculate angle deviation from nearest cardinal direction for each segment
3968
+ 3) Rotate polygon by median deviation angle to align segments with xy coord axes (cardinal directions)
3969
+ 4) For vertical segments replace X coordinates of their points with mean value
3970
+ For horizontal segments replace Y coordinates of their points with mean value
3971
+ 5) Rotate back
3972
+
3973
+ :Parameters:
3974
+ - `polygon: shapely polygon object containing simplified building.
3975
+ - `maxAngleChange: angle (0,45> degrees. Sets the maximum angle deviation
3976
+ from the cardinal direction for the segment to be still
3977
+ considered to continue in the same direction as the
3978
+ previous segment.
3979
+ - `skewTolerance: angle <0,45> degrees. Sets skew tolerance for segments that
3980
+ are at 45˚±Tolerance angle from the overall rectangular shape
3981
+ of the polygon. Useful when preserving e.g. bay windows on a
3982
+ house.
3983
+
3984
+ :Returns:
3985
+ - polyOrthog: orthogonalized shapely polygon where all angles are 90 or 180 degrees
3986
+
3987
+ :Returns Type:
3988
+ shapely Polygon
3989
+ """
3990
+ # Check if polygon has inner rings that we want to orthogonalize as well
3991
+ rings = [Polygon (polygon .exterior )]
3992
+ for inner in list (polygon .interiors ):
3993
+ rings .append (Polygon (inner ))
3994
+
3995
+ polyOrthog = []
3996
+ for polySimple in rings :
3997
+
3998
+ # Get angles from cardinal directions of all segments
3999
+ orgAngle , corAngle , dirAngle = calculate_segment_angles (polySimple )
4000
+
4001
+ # Calculate median angle that will be used for rotation
4002
+ if statistics .stdev (corAngle ) < 30 :
4003
+ medAngle = statistics .median (corAngle )
4004
+ # avAngle = statistics.mean(corAngle)
4005
+ else :
4006
+ medAngle = 45 # Account for cases when building is at ~45˚ and we can't decide if to turn clockwise or anti-clockwise
4007
+
4008
+ # Rotate polygon to align its edges to cardinal directions
4009
+ polySimpleR = rotate_polygon (polySimple , medAngle )
4010
+
4011
+ # Get directions of rotated polygon segments
4012
+ orgAngle , corAngle , dirAngle = calculate_segment_angles (
4013
+ polySimpleR , maxAngleChange
4014
+ )
4015
+
4016
+ # Get Lat/Lon of rotated polygon points
4017
+ rotatedX = polySimpleR .exterior .xy [0 ].tolist ()
4018
+ rotatedY = polySimpleR .exterior .xy [1 ].tolist ()
4019
+
4020
+ # Scan backwards to check if starting segment is a continuation of straight region in the same direction
4021
+ shift = 0
4022
+ for i in range (1 , len (dirAngle )):
4023
+ if dirAngle [0 ] == dirAngle [- i ]:
4024
+ shift = i
4025
+ else :
4026
+ break
4027
+ # If the first segment is part of continuing straight region then reset the index to its beginning
4028
+ if shift != 0 :
4029
+ dirAngle = dirAngle [- shift :] + dirAngle [:- shift ]
4030
+ orgAngle = orgAngle [- shift :] + orgAngle [:- shift ]
4031
+ rotatedX = (
4032
+ rotatedX [- shift - 1 : - 1 ] + rotatedX [:- shift ]
4033
+ ) # First and last points are the same in closed polygons
4034
+ rotatedY = rotatedY [- shift - 1 : - 1 ] + rotatedY [:- shift ]
4035
+
4036
+ # Fix 180 degree turns (N->S, S->N, E->W, W->E)
4037
+ # Subtract two adjacent directions and if the difference is 2, which means we have 180˚ turn (0,1,3 are OK) then use the direction of the previous segment
4038
+ dirAngleRoll = dirAngle [1 :] + dirAngle [0 :1 ]
4039
+ dirAngle = [
4040
+ (
4041
+ dirAngle [i - 1 ]
4042
+ if abs (dirAngle [i ] - dirAngleRoll [i ]) == 2
4043
+ else dirAngle [i ]
4044
+ )
4045
+ for i in range (len (dirAngle ))
4046
+ ]
4047
+
4048
+ # Cycle through all segments
4049
+ # Adjust points coordinates by taking the average of points in segment
4050
+ dirAngle .append (dirAngle [0 ]) # Append dummy value
4051
+ orgAngle .append (orgAngle [0 ]) # Append dummy value
4052
+ segmentBuffer = (
4053
+ []
4054
+ ) # Buffer for determining which segments are part of one large straight line
4055
+
4056
+ for i in range (0 , len (dirAngle ) - 1 ):
4057
+ # Preserving skewed walls: Leave walls that are obviously meant to be skewed 45˚+/- tolerance˚ (e.g.angle 30-60 degrees) off main walls as untouched
4058
+ if orgAngle [i ] % 90 > (45 - skewTolerance ) and orgAngle [i ] % 90 < (
4059
+ 45 + skewTolerance
4060
+ ):
4061
+ continue
4062
+
4063
+ # Dealing with adjacent segments following the same direction
4064
+ segmentBuffer .append (i )
4065
+ if (
4066
+ dirAngle [i ] == dirAngle [i + 1 ]
4067
+ ): # If next segment is of same orientation, we need 180 deg angle for straight line. Keep checking.
4068
+ if orgAngle [i + 1 ] % 90 > (45 - skewTolerance ) and orgAngle [
4069
+ i + 1
4070
+ ] % 90 < (45 + skewTolerance ):
4071
+ pass
4072
+ else :
4073
+ continue
4074
+
4075
+ if dirAngle [i ] in {0 , 2 }: # for N,S segments avereage x coordinate
4076
+ tempX = statistics .mean (
4077
+ rotatedX [segmentBuffer [0 ] : segmentBuffer [- 1 ] + 2 ]
4078
+ )
4079
+ # Update with new coordinates
4080
+ rotatedX [segmentBuffer [0 ] : segmentBuffer [- 1 ] + 2 ] = [tempX ] * (
4081
+ len (segmentBuffer ) + 1
4082
+ ) # Segment has 2 points therefore +1
4083
+ elif dirAngle [i ] in {1 , 3 }: # for E,W segments avereage y coordinate
4084
+ tempY = statistics .mean (
4085
+ rotatedY [segmentBuffer [0 ] : segmentBuffer [- 1 ] + 2 ]
4086
+ )
4087
+ # Update with new coordinates
4088
+ rotatedY [segmentBuffer [0 ] : segmentBuffer [- 1 ] + 2 ] = [tempY ] * (
4089
+ len (segmentBuffer ) + 1
4090
+ )
4091
+
4092
+ if (
4093
+ 0 in segmentBuffer
4094
+ ): # Copy change in first point to its last point so we don't lose it during Reverse shift
4095
+ rotatedX [- 1 ] = rotatedX [0 ]
4096
+ rotatedY [- 1 ] = rotatedY [0 ]
4097
+
4098
+ segmentBuffer = []
4099
+
4100
+ # Reverse shift so we get polygon with the same start/end point as before
4101
+ if shift != 0 :
4102
+ rotatedX = (
4103
+ rotatedX [shift :] + rotatedX [1 : shift + 1 ]
4104
+ ) # First and last points are the same in closed polygons
4105
+ rotatedY = rotatedY [shift :] + rotatedY [1 : shift + 1 ]
4106
+ else :
4107
+ rotatedX [0 ] = rotatedX [- 1 ] # Copy updated coordinates to first node
4108
+ rotatedY [0 ] = rotatedY [- 1 ]
4109
+
4110
+ # Create polygon from new points
4111
+ polyNew = Polygon (zip (rotatedX , rotatedY ))
4112
+
4113
+ # Rotate polygon back
4114
+ polyNew = rotate_polygon (polyNew , - medAngle )
4115
+
4116
+ # Add to list of finihed rings
4117
+ polyOrthog .append (polyNew )
4118
+
4119
+ # Recreate the original object
4120
+ polyOrthog = Polygon (
4121
+ polyOrthog [0 ].exterior , [inner .exterior for inner in polyOrthog [1 :]]
4122
+ )
4123
+ return polyOrthog
4124
+
4125
+ if isinstance (filepath , str ):
4126
+
4127
+ buildings = gpd .read_file (filepath )
4128
+ elif isinstance (filepath , gpd .GeoDataFrame ):
4129
+ buildings = filepath
4130
+ else :
4131
+ raise TypeError ("Input must be a file path or a GeoDataFrame." )
4132
+
4133
+ for i in range (0 , len (buildings )):
4134
+ build = buildings .loc [i , "geometry" ]
4135
+
4136
+ if build .geom_type == "MultiPolygon" : # Multipolygons
4137
+ multipolygon = []
4138
+
4139
+ for poly in build :
4140
+ buildOrtho = orthogonalize_polygon (poly , maxAngleChange , skewTolerance )
4141
+ multipolygon .append (buildOrtho )
4142
+
4143
+ buildings .loc [i , "geometry" ] = gpd .GeoSeries (
4144
+ MultiPolygon (multipolygon )
4145
+ ).values # Workaround for Pandas/Geopandas bug
4146
+ # buildings.loc[i, 'geometry'] = MultiPolygon(multipolygon) # Does not work
4147
+
4148
+ else : # Polygons
4149
+
4150
+ buildOrtho = orthogonalize_polygon (build )
4151
+
4152
+ buildings .loc [i , "geometry" ] = buildOrtho
4153
+
4154
+ if output is not None :
4155
+ buildings .to_file (output )
4156
+ else :
4157
+ return buildings
0 commit comments