Skip to content

Commit 9466905

Browse files
committed
Add orthogonalize function
1 parent 05a7380 commit 9466905

File tree

1 file changed

+365
-0
lines changed

1 file changed

+365
-0
lines changed

samgeo/common.py

Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3790,3 +3790,368 @@ def intensity_median(region, intensity_image):
37903790
gdf2.sort_values("label", inplace=True)
37913791
df = gdf2
37923792
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

Comments
 (0)