Skip to content

Commit c33a109

Browse files
authored
Merge pull request #60 from sjsrey/compact
Add option for maximizing compactness when filling gaps
2 parents 9e41ccc + d27f28c commit c33a109

File tree

13 files changed

+877
-410
lines changed

13 files changed

+877
-410
lines changed

ci/310-latest.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ channels:
44
dependencies:
55
- python=3.10
66
- libpysal
7+
- esda
78
- geopandas
89
- packaging
910
- pytest

ci/310-oldest.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ dependencies:
77
- geopandas=0.10.2
88
- scipy=1.8
99
- pandas=1.5
10+
- esda
1011
- packaging
1112
- pytest
1213
- pytest-cov

ci/311-latest.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ channels:
44
dependencies:
55
- python=3.11
66
- libpysal
7+
- esda
78
- geopandas
89
- packaging
910
- pytest

ci/312-dev.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ dependencies:
1515
- --pre --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple --extra-index-url https://pypi.org/simple
1616
- pandas
1717
- git+https://github.com/geopandas/geopandas.git@main
18-
- git+https://github.com/pysal/libpysal.git@main
18+
- git+https://github.com/pysal/libpysal.git@main
19+
- git+https://github.com/pysal/esda.git@main

ci/312-latest.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ channels:
44
dependencies:
55
- python=3.12
66
- libpysal
7+
- esda
78
- packaging
89
- pytest
910
- pytest-cov

geoplanar/gap.py

Lines changed: 49 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
import pandas as pd
66
import shapely
77
from packaging.version import Version
8+
from collections import defaultdict
9+
from esda.shape import isoperimetric_quotient
10+
811

912
__all__ = ["gaps", "fill_gaps", "snap"]
1013

@@ -57,51 +60,38 @@ def gaps(gdf):
5760
return polygons.drop(poly_idx).reset_index(drop=True)
5861

5962

60-
def fill_gaps(gdf, gap_df=None, largest=True, inplace=False):
61-
"""Fill any gaps in a geodataframe.
63+
def fill_gaps(gdf, gap_df=None, strategy='largest', inplace=False):
64+
"""Fill gaps in a GeoDataFrame by merging them with neighboring polygons.
6265
6366
Parameters
6467
----------
65-
66-
gdf : GeoDataFrame with polygon (multipolygon) GeoSeries
67-
68-
69-
gap_df: GeoDataFrame with gaps to fill
70-
If None, gaps will be determined
71-
72-
largest: boolean (Default: True)
73-
Merge each gap with its largest (True), or smallest (False) neighbor.
74-
If None, merge with any neighbor non-deterministically but performantly.
75-
76-
inplace: boolean (default: False)
77-
Change the geoseries of current dataframe
78-
68+
gdf : GeoDataFrame
69+
A GeoDataFrame containing polygon or multipolygon geometries.
70+
71+
gap_df : GeoDataFrame, optional
72+
A GeoDataFrame containing the gaps to be filled. If None, gaps will be
73+
automatically detected within `gdf`.
74+
75+
strategy : {'smallest', 'largest', 'compact', None}, default 'largest'
76+
Strategy to determine how gaps are merged with neighboring polygons:
77+
- 'smallest': Merge each gap with the smallest neighboring polygon.
78+
- 'largest' : Merge each gap with the largest neighboring polygon.
79+
- 'compact' : Merge each gap with the neighboring polygon that results in
80+
the new polygon having the highest compactness
81+
(isoperimetric quotient).
82+
- None : Merge each gap with the first available neighboring polygon
83+
(order is indeterminate).
84+
85+
inplace : bool, default False
86+
If True, modify the input GeoDataFrame in place. Otherwise, return a new
87+
GeoDataFrame with the gaps filled.
7988
8089
Returns
8190
-------
82-
83-
_gaps : GeoDataFrame with gap polygons
84-
85-
Examples
86-
--------
87-
>>> p1 = box(0,0,10,10)
88-
>>> p2 = Polygon([(10,10), (12,8), (10,6), (12,4), (10,2), (20,5)])
89-
>>> gdf = geopandas.GeoDataFrame(geometry=[p1,p2])
90-
>>> gdf.area
91-
0 100.0
92-
1 32.0
93-
dtype: float64
94-
>>> h = geoplanar.gaps(gdf)
95-
>>> h.area
96-
array([4., 4.])
97-
>>> gdf1 = geoplanar.fill_gaps(gdf)
98-
>>> gdf1.area
99-
0 108.0
100-
1 32.0
101-
dtype: float64
91+
GeoDataFrame or None
92+
A new GeoDataFrame with gaps filled if `inplace` is False. Otherwise,
93+
modifies `gdf` in place and returns None.
10294
"""
103-
from collections import defaultdict
104-
10595
if gap_df is None:
10696
gap_df = gaps(gdf)
10797

@@ -116,13 +106,32 @@ def fill_gaps(gdf, gap_df=None, largest=True, inplace=False):
116106
gap_idx, gdf_idx = gdf.sindex.query(gap_df.geometry, predicate="intersects")
117107

118108
to_merge = defaultdict(set)
109+
119110
for g_ix in range(len(gap_df)):
120111
neighbors = gdf_idx[gap_idx == g_ix]
121-
if largest is None: # don't care which polygon is a gap attached to
112+
113+
if strategy == 'compact':
114+
# Find the neighbor that results in the highest IQ
115+
gap_geom = shapely.make_valid(gap_df.geometry.iloc[g_ix])
116+
best_iq = -1
117+
best_neighbor = None
118+
neighbor_geometries = gdf.geometry.iloc[neighbors].apply(shapely.make_valid)
119+
for neighbor, neighbor_geom in zip(neighbors, neighbor_geometries):
120+
combined_geom = shapely.union_all(
121+
[neighbor_geom, gap_geom]
122+
)
123+
iq = isoperimetric_quotient(combined_geom)
124+
if iq > best_iq:
125+
best_iq = iq
126+
best_neighbor = neighbor
127+
to_merge[best_neighbor].add(g_ix)
128+
elif strategy is None: # don't care which polygon we attach cap to
122129
to_merge[gdf.index[neighbors[0]]].add(g_ix)
123-
elif largest:
130+
elif strategy == 'largest':
131+
# Attach to the largest neighbor
124132
to_merge[gdf.iloc[neighbors].area.idxmax()].add(g_ix)
125133
else:
134+
# Attach to the smallest neighbor
126135
to_merge[gdf.iloc[neighbors].area.idxmin()].add(g_ix)
127136

128137
new_geom = []

geoplanar/overlap.py

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import libpysal
66
import numpy as np
77
from packaging.version import Version
8+
from esda.shape import isoperimetric_quotient
89

910
__all__ = [
1011
"overlaps",
@@ -32,7 +33,7 @@ def overlaps(gdf):
3233
return gdf.sindex.query_bulk(gdf.geometry, predicate="overlaps")
3334

3435

35-
def trim_overlaps(gdf, largest=True, inplace=False):
36+
def trim_overlaps(gdf, strategy='largest', inplace=False):
3637
"""Trim overlapping polygons
3738
3839
Note
@@ -46,11 +47,14 @@ def trim_overlaps(gdf, largest=True, inplace=False):
4647
4748
gdf: geodataframe with polygon geometries
4849
49-
largest: boolean (Default: True)
50-
True: trim the larger of the pair of overlapping polygons,
51-
False: trim the smaller polygon.
52-
If None, trim either polygon non-deterministically but performantly.
53-
50+
strategy : {'smallest', 'largest', 'compact', None}, default 'largest'
51+
Strategy to determine which polygon to trim.
52+
- 'smallest': Trim the smallest polygon.
53+
- 'largest' : Trim the largest polygon.
54+
- 'compact' : Trim the polygon yielding the most compact modified polygon.
55+
(isoperimetric quotient).
56+
- None : Trim either polygon non-deterministically but performantly.
57+
5458
Returns
5559
-------
5660
@@ -67,35 +71,43 @@ def trim_overlaps(gdf, largest=True, inplace=False):
6771

6872
geom_col_idx = gdf.columns.get_loc(gdf.geometry.name)
6973

70-
if largest is None: # don't care which polygon to trim
74+
if strategy is None: # don't care which polygon to trim
7175
for i, j in intersections:
7276
if i != j:
7377
left = gdf.geometry.iloc[i]
7478
right = gdf.geometry.iloc[j]
75-
right = gdf.geometry.iloc[j].difference(gdf.geometry.iloc[i])
76-
gdf.iloc[j, geom_col_idx] = right
77-
elif largest:
79+
gdf.iloc[j, geom_col_idx] = right.difference(left)
80+
elif strategy=='largest':
7881
for i, j in intersections:
7982
if i != j:
8083
left = gdf.geometry.iloc[i]
8184
right = gdf.geometry.iloc[j]
82-
if left.area < right.area:
83-
right = gdf.geometry.iloc[j].difference(gdf.geometry.iloc[i])
84-
gdf.iloc[j, geom_col_idx] = right
85+
if left.area > right.area: # trim left
86+
gdf.iloc[i, geom_col_idx] = left.difference(right)
8587
else:
86-
left = gdf.geometry.iloc[i].difference(gdf.geometry.iloc[j])
87-
gdf.iloc[i, geom_col_idx] = left
88-
else:
88+
gdf.iloc[j, geom_col_idx] = right.difference(left)
89+
elif strategy=='smallest':
8990
for i, j in intersections:
9091
if i != j:
9192
left = gdf.geometry.iloc[i]
9293
right = gdf.geometry.iloc[j]
93-
if left.area > right.area:
94-
right = gdf.geometry.iloc[j].difference(gdf.geometry.iloc[i])
95-
gdf.iloc[j, geom_col_idx] = right
94+
if left.area < right.area: # trim left
95+
gdf.iloc[i, geom_col_idx] = left.difference(right)
9696
else:
97-
left = gdf.geometry.iloc[i].difference(gdf.geometry.iloc[j])
98-
gdf.iloc[i, geom_col_idx] = left
97+
gdf.iloc[j, geom_col_idx] = right.difference(left)
98+
elif strategy=='compact':
99+
for i, j in intersections:
100+
if i != j:
101+
left = gdf.geometry.iloc[i]
102+
right = gdf.geometry.iloc[j]
103+
left_c = left.difference(right)
104+
right_c = right.difference(left)
105+
iq_left = isoperimetric_quotient(left_c)
106+
iq_right = isoperimetric_quotient(right_c)
107+
if iq_left > iq_right: # trimming left is more compact than right
108+
gdf.iloc[i, geom_col_idx] = left_c
109+
else:
110+
gdf.iloc[j, geom_col_idx] = right_c
99111
return gdf
100112

101113

geoplanar/tests/test_compact.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import pytest
2+
from shapely.geometry import box
3+
import geopandas as gpd
4+
from geoplanar import fill_gaps # Updated module name
5+
from packaging.version import Version
6+
import pytest
7+
8+
@pytest.mark.skipif(Version(gpd.__version__) == Version("0.10.2"), reason="Missing pygeos")
9+
def test_fill_gaps_compact_visual_case():
10+
"""Test fill_gaps behavior with compact=True and compact=False using specific geometries."""
11+
# Define four polygons with a visible gap
12+
p1 = box(0, 0, 10, 10) # Left rectangle
13+
p2 = box(10, 0, 40, 2) # Bottom thin rectangle
14+
p3 = box(10, 3, 40, 10) # Top thin rectangle
15+
p4 = box(40, 0, 100, 10) # Right rectangle
16+
17+
# Create GeoDataFrame
18+
gdf = gpd.GeoDataFrame(geometry=[p1, p2, p3, p4])
19+
20+
# Fill gaps with compact
21+
filled_gdf_compact = fill_gaps(gdf, strategy='compact')
22+
compact_geom_count = len(filled_gdf_compact)
23+
24+
# Fill gaps with largest (default)
25+
filled_gdf_default = fill_gaps(gdf, strategy='largest')
26+
default_geom_count = len(filled_gdf_default)
27+
28+
# Assert the number of geometries is the same after filling gaps
29+
assert compact_geom_count == default_geom_count, (
30+
"The number of geometries after filling gaps should remain consistent."
31+
)
32+
33+
# Assert the geometries differ when compact=True vs largest=True
34+
assert not filled_gdf_compact.equals(filled_gdf_default), (
35+
"The resulting geometries should differ between compact=True and largest=True."
36+
)
37+
38+
# Verify that gaps are filled completely
39+
filled_gaps_compact = filled_gdf_compact.unary_union
40+
filled_gaps_default = filled_gdf_default.unary_union
41+
assert filled_gaps_compact.is_valid, "The geometries with compact=True must be valid."
42+
assert filled_gaps_default.is_valid, "The geometries with largest=True must be valid."
43+

geoplanar/tests/test_gap.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,11 @@ def test_fill_gaps(self):
3636
assert_equal(gdf1.area.values, numpy.array([108.0, 32.0]))
3737

3838
def test_fill_gaps_smallest(self):
39-
gdf1 = fill_gaps(self.gdf, largest=False)
40-
assert_equal(gdf1.area.values, numpy.array([100.0, 40.0]))
41-
42-
gdf1 = fill_gaps(self.gdf_str, largest=False)
39+
gdf1 = fill_gaps(self.gdf, strategy='smallest')
4340
assert_equal(gdf1.area.values, numpy.array([100.0, 40.0]))
4441

4542
def test_fill_gaps_none(self):
46-
gdf1 = fill_gaps(self.gdf, largest=None)
47-
assert_equal(gdf1.area.values, numpy.array([108.0, 32.0]))
48-
49-
gdf1 = fill_gaps(self.gdf_str, largest=None)
43+
gdf1 = fill_gaps(self.gdf, strategy=None)
5044
assert_equal(gdf1.area.values, numpy.array([108.0, 32.0]))
5145

5246
def test_fill_gaps_gaps_df(self):

geoplanar/tests/test_overlap.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import numpy
55
from numpy.testing import assert_equal
66
from shapely.geometry import box
7+
from packaging.version import Version
8+
import pytest
79

810
from geoplanar.overlap import (
911
is_overlapping,
@@ -34,29 +36,30 @@ def test_trim_overlaps(self):
3436
assert_equal(gdf1.area.values, numpy.array([96.0, 8.0]))
3537

3638
def test_trim_overlaps_smallest(self):
37-
gdf1 = trim_overlaps(self.gdf, largest=False)
38-
assert_equal(gdf1.area.values, numpy.array([100.0, 4.0]))
39-
40-
gdf1 = trim_overlaps(self.gdf_str, largest=False)
39+
gdf1 = trim_overlaps(self.gdf, strategy='smallest')
4140
assert_equal(gdf1.area.values, numpy.array([100.0, 4.0]))
4241

4342
def test_trim_overlaps_random(self):
44-
gdf1 = trim_overlaps(self.gdf, largest=None)
45-
assert_equal(gdf1.area.values, numpy.array([100.0, 4.0]))
46-
47-
gdf1 = trim_overlaps(self.gdf_str, largest=None)
43+
gdf1 = trim_overlaps(self.gdf, strategy=None)
4844
assert_equal(gdf1.area.values, numpy.array([100.0, 4.0]))
4945

46+
@pytest.mark.skipif(Version(geopandas.__version__) == Version("0.10.2"), reason="Missing pygeos")
5047
def test_trim_overlaps_multiple(self):
51-
gdf1 = trim_overlaps(self.gdf2, largest=False)
52-
assert_equal(gdf1.area.values, numpy.array([100.0, 100.0, 0.0]))
48+
gdf1 = trim_overlaps(self.gdf2, strategy='largest')
49+
assert_equal(gdf1.area.values, numpy.array([96, 96.0, 8.0]))
5350

54-
gdf1 = trim_overlaps(self.gdf2, largest=None)
51+
gdf1 = trim_overlaps(self.gdf2, strategy=None)
5552
assert_equal(gdf1.area.values, numpy.array([100.0, 100.0, 0.0]))
5653

5754
gdf1 = trim_overlaps(self.gdf2)
5855
assert_equal(gdf1.area.values, numpy.array([96.0, 96.0, 8.0]))
5956

57+
gdf1 = trim_overlaps(self.gdf2, strategy='smallest')
58+
assert_equal(gdf1.area.values, numpy.array([100.0, 100.0, 0.0]))
59+
60+
gdf = trim_overlaps(self.gdf2, strategy='compact')
61+
assert_equal(gdf1.area.values, numpy.array([100.0, 100.0, 0.0]))
62+
6063
def test_merge_overlaps(self):
6164
gdf1 = merge_overlaps(self.gdf, 10, 0)
6265
assert_equal(gdf1.area.values, numpy.array([104]))

0 commit comments

Comments
 (0)