Highlight polygons that share a side/edge

Question:

How do I create a… —I don’t know; list, dict of all the polygons, in a GeoDataFrame, that share a side/edge. The polygons will intersect but never cross.

import geopandas as gpd
from shapely.geometry import Polygon
import matplotlib.pyplot as plt

polys = gpd.GeoSeries([Polygon([(0,0), (2,0), (2, 1.5), (2,2), (0,2)]),
                     Polygon([(0,2), (2,2), (2,4), (0,4)]),
                     Polygon([(2,0), (5,0), (5,1.5), (2,1.5)]),
                     Polygon([(3,3), (5,3), (5,5), (3,5)])])

fp = gpd.GeoDataFrame({'geometry': polys, 'name': ['a', 'b', 'c', 'd'],
                      'grnd': [25, 25, 25, 25],
                      'rf': [29, 35, 26, 31]})

fig, ax = plt.subplots(figsize=(5, 5))
fp.plot(ax=ax, alpha=0.3, cmap='tab10', edgecolor='k',)
fp.apply(lambda x: ax.annotate(text=x['name'], xy=x.geometry.centroid.coords[0], ha='center'), axis=1)
plt.show()

enter image description here

for i, row in fp.iterrows():
    oring = list(row.geometry.exterior.coords)#, row['ground_height']
    if row.geometry.exterior.is_ccw == False:
        #-- to get proper orientation of the normals
        oring.reverse() 
    for (j, v) in enumerate(oring[:-1]):
        print(oring[j][0], oring[j][1], oring[j+1][0], oring[j+1][1], row['name'])

Expected result:

0.0 0.0 2.0 0.0 a
2.0 0.0 2.0 1.5 a c
2.0 1.5 2.0 2.0 a 
2.0 2.0 0.0 2.0 a b
0.0 2.0 0.0 0.0 a
0.0 2.0 2.0 2.0 b a
2.0 2.0 2.0 4.0 b 
2.0 4.0 0.0 4.0 b
0.0 4.0 0.0 2.0 b... and so on
Asked By: arkriger

||

Answers:

Are you looking for geopandas.GeoSeries.touches?

You could do something like the following:

import geopandas as gpd
from shapely.geometry import Polygon
import matplotlib.pyplot as plt

polys = gpd.GeoSeries([
    Polygon([(0,0), (2,0), (2, 1.5), (2,2), (0,2)]),
    Polygon([(0,2), (2,2), (2,4), (0,4)]),
    Polygon([(2,0), (5,0), (5,1.5), (2,1.5)]),
    Polygon([(3,3), (5,3), (5,5), (3,5)])
])

fp = gpd.GeoDataFrame({
    'geometry': polys, 
    'name': ['a', 'b', 'c', 'd'],
    'grnd': [25, 25, 25, 25],
    'rf': [29, 35, 26, 31]
})

result = {
    row['name']: {
        'touching polygons': list(fp.loc[fp.geometry.touches(row.geometry), 'name'].values)
    }
    for i, row in fp.iterrows()
}

output:

{
    'a': {'touching polygons': ['b', 'c']}, 
    'b': {'touching polygons': ['a']}, 
    'c': {'touching polygons': ['a']}, 
    'd': {'touching polygons': []}
}
Answered By: Zach Flanders

Looking at the Expected result, I can say that, no spatial predicate available to operate and get that kind of result. Intersects or touches between 2 geometries will get some false-positive results.

Here I implement a simple check, same_lineQ(x1y1, x2y2), as a procedure to use in place where the missing spatial op is needed.

# PART 1
import geopandas as gpd
from shapely.geometry import Polygon, LineString, Point
import matplotlib.pyplot as plt
import pandas as pd

polys = gpd.GeoSeries([
    Polygon([(0,0), (2,0), (2, 1.5), (2,2), (0,2)]),
    Polygon([(0,2), (2,2), (2,4), (0,4)]),
    Polygon([(2,0), (5,0), (5,1.5), (2,1.5)]),
    Polygon([(3,3), (5,3), (5,5), (3,5)])
])

fp = gpd.GeoDataFrame({
    'geometry': polys, 
    'name': ['a', 'b', 'c', 'd'],
    'grnd': [25, 25, 25, 25],
    'rf': [29, 35, 26, 31]
})

fig, ax = plt.subplots(figsize=(5/2, 5/2))
fp.plot(ax=ax, alpha=0.3, cmap='tab10', edgecolor='k',)
fp.apply(lambda x: ax.annotate(text=x['name'], xy=x.geometry.centroid.coords[0], ha='center'), axis=1)

# Part 2
# Collect all the line segments from all polygons
def get_all_xy0xy1(p1, attrib="none"):
    """
    p1: a Polygon object, has single `exterior`
    returns: list of [[x0,y0],[x1,y1]] ready for LineString creation
    eg.: LineString([(0, 0), (9, 9)])
    """
    xy0_xy1_list = []
    attribs = []
    for ix,xy in enumerate(zip(p1.exterior.xy[0], p1.exterior.xy[1])):
        # 3 or more items
        #print(ix,xy)  #either x, or y separately
        if ix>0:
            #print([prev, xy]) #list of x,y; from-to
            xy0_xy1_list.append([prev, xy])
            attribs.append(attrib)
        prev = xy
    return xy0_xy1_list, attribs

# Line segments are collected in `all_line_segs`
all_line_segs = []
names = []
for ix, row in fp.iterrows():
    name = row['name']
    geom = row.geometry
    all_line_segs += get_all_xy0xy1(geom, name)[0]
    names += get_all_xy0xy1(geom, name)[1]

# Create a dataframe using the line segments
line_segs = pd.DataFrame({
    'xy1_xy2': all_line_segs, 
    'name': names
})

# Part 3
def same_lineQ(x1y1, x2y2):
    """
    Input: x1y1, x2y2; two list of (x,y).
    Returns:
     True if they represent the same LineString
       ignoring the direction
     else returns False.
    """
    return (x1y1[0] in x2y2) and (x1y1[1] in x2y2)

for ir, irow in line_segs.iterrows():
    iname = irow['name']
    ixys = irow['xy1_xy2']
    targets = set()
    for kr, krow in line_segs.iterrows():
        if ir != kr:
            kname = krow['name']
            if iname != kname:
                kxys = krow['xy1_xy2']

                if same_lineQ(ixys, kxys)==True:
                    #print(ixys, kxys, same_lineQ(ixys, kxys))
                    targets.update(kname)

        else:
            pass
    if len(targets)==0:
        print(ixys[0][0],ixys[0][1], ixys[1][0],ixys[1][1], iname)
    else:    
        print(ixys[0][0],ixys[0][1], ixys[1][0],ixys[1][1], iname, targets.pop())

Output:

0.0 0.0 2.0 0.0 a
2.0 0.0 2.0 1.5 a c
2.0 1.5 2.0 2.0 a
2.0 2.0 0.0 2.0 a b
0.0 2.0 0.0 0.0 a
0.0 2.0 2.0 2.0 b a
2.0 2.0 2.0 4.0 b
2.0 4.0 0.0 4.0 b
0.0 4.0 0.0 2.0 b
2.0 0.0 5.0 0.0 c
5.0 0.0 5.0 1.5 c
5.0 1.5 2.0 1.5 c
2.0 1.5 2.0 0.0 c a
3.0 3.0 5.0 3.0 d
5.0 3.0 5.0 5.0 d
5.0 5.0 3.0 5.0 d
3.0 5.0 3.0 3.0 d

Edit

If the geodataframe is more complex, for example, some polygons have holes, or some rows have MultiPolygon instead of Polygon, The code above won’t work. It only work with geodataframe that have polygons without holes only.

So what to do in such situation?

One approach is to explode the rows that do not have simple polygon, i.e. have polygon with holes or multipolygon, and get a resulting geodataframe that have all simple polygons.

Take this modified geodataframe as an example:

modified_df

(Note e is MultiPolygon of 2 Polygons, one of it has single hole. And f is simple Polygon.)

If it is used with the code above, the output will be:

0.0 0.0 2.0 0.0 a
2.0 0.0 2.0 1.5 a c
2.0 1.5 2.0 2.0 a
2.0 2.0 0.0 2.0 a b
0.0 2.0 0.0 0.0 a
0.0 2.0 2.0 2.0 b a
2.0 2.0 2.0 4.0 b
2.0 4.0 0.0 4.0 b
0.0 4.0 0.0 2.0 b
2.0 0.0 5.0 0.0 c
5.0 0.0 5.0 1.5 c
5.0 1.5 2.0 1.5 c
2.0 1.5 2.0 0.0 c a
3.0 3.0 5.0 3.0 d
5.0 3.0 5.0 5.0 d e
5.0 5.0 3.0 5.0 d
3.0 5.0 3.0 3.0 d
6.5 0.0 7.5 0.0 f
7.5 0.0 7.5 1.5 f
7.5 1.5 6.5 1.5 f e
6.5 1.5 6.5 0.0 f
6.0 1.0 8.0 1.0 e
8.0 1.0 8.0 3.0 e
8.0 3.0 6.0 3.0 e
6.0 3.0 6.0 1.0 e
5.0 3.0 8.0 3.0 e
8.0 3.0 8.0 5.0 e
8.0 5.0 5.0 5.0 e
5.0 5.0 5.0 3.0 e d
6.5 2.5 7.5 2.5 e
7.5 2.5 7.5 1.5 e
7.5 1.5 6.5 1.5 e f
6.5 1.5 6.5 2.5 e
Answered By: swatchai

Looking at the expected result again, I would first create a series that includes all of the line segments and then check if each line segment both touches the polygon and that the length of intersection is greater than 0:

import geopandas as gpd
from shapely.geometry import Polygon, LineString
import matplotlib.pyplot as plt

polys = gpd.GeoSeries([
    Polygon([(0,0), (2,0), (2, 1.5), (2,2), (0,2)]),
    Polygon([(0,2), (2,2), (2,4), (0,4)]),
    Polygon([(2,0), (5,0), (5,1.5), (2,1.5)]),
    Polygon([(3,3), (5,3), (5,5), (3,5)])
])

fp = gpd.GeoDataFrame({
    'geometry': polys, 
    'name': ['a', 'b', 'c', 'd'],
    'grnd': [25, 25, 25, 25],
    'rf': [29, 35, 26, 31]
})

# Create series of all the line segments
lines = fp.geometry.apply(lambda x: list(map(
    LineString, 
    zip(x.boundary.coords[:-1], x.boundary.coords[1:]))
)).explode()

result = {
    str(line): list(fp.loc[
        (fp.geometry.touches(line)) # line touches the polygon
        & (fp.geometry.intersection(line).length > 0), # And the intersection is more than just a point
        'name'
    ].values)
    for line in lines
}

output:

{'LINESTRING (0 0, 2 0)': ['a'],
 'LINESTRING (2 0, 2 1.5)': ['a', 'c'],
 'LINESTRING (2 1.5, 2 2)': ['a'],
 'LINESTRING (2 2, 0 2)': ['a', 'b'],
 'LINESTRING (0 2, 0 0)': ['a'],
 'LINESTRING (0 2, 2 2)': ['a', 'b'],
 'LINESTRING (2 2, 2 4)': ['b'],
 'LINESTRING (2 4, 0 4)': ['b'],
 'LINESTRING (0 4, 0 2)': ['b'],
 'LINESTRING (2 0, 5 0)': ['c'],
 'LINESTRING (5 0, 5 1.5)': ['c'],
 'LINESTRING (5 1.5, 2 1.5)': ['c'],
 'LINESTRING (2 1.5, 2 0)': ['a', 'c'],
 'LINESTRING (3 3, 5 3)': ['d'],
 'LINESTRING (5 3, 5 5)': ['d'],
 'LINESTRING (5 5, 3 5)': ['d'],
 'LINESTRING (3 5, 3 3)': ['d']}
Answered By: Zach Flanders