deoverlap 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) [2025] [Pietro Leoni]
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.
@@ -0,0 +1,202 @@
1
+ Metadata-Version: 2.4
2
+ Name: deoverlap
3
+ Version: 0.1.1
4
+ Summary: A high-level toolkit for de-overlapping Shapely geometries.
5
+ Author-email: Pietro Leoni <pietro.leoni@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) [2025] [Pietro Leoni]
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ Project-URL: Homepage, https://github.com/piLeoni/deoverlap
28
+ Project-URL: Bug Tracker, https://github.com/piLeoni/deoverlap
29
+ Classifier: Programming Language :: Python :: 3
30
+ Classifier: Programming Language :: Python :: 3.10
31
+ Classifier: Programming Language :: Python :: 3.11
32
+ Classifier: Programming Language :: Python :: 3.12
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Operating System :: OS Independent
35
+ Classifier: Topic :: Scientific/Engineering :: GIS
36
+ Classifier: Topic :: Utilities
37
+ Requires-Python: >=3.10
38
+ Description-Content-Type: text/markdown
39
+ License-File: LICENSE
40
+ Requires-Dist: shapely>=2.0
41
+ Requires-Dist: tqdm
42
+ Provides-Extra: test
43
+ Requires-Dist: pytest; extra == "test"
44
+ Requires-Dist: matplotlib; extra == "test"
45
+ Dynamic: license-file
46
+
47
+ # Deoverlap
48
+
49
+ A high-level Python module for de-overlapping a set of Shapely geometric objects. It offers two main modes of operation: a fast "flat" mode that returns simple geometries, and a more powerful "structured" mode that preserves geometry types and can track the origin of removed pieces.
50
+
51
+ ![](test_outputs/test_tangent_circles_and_line_s_F_k_T.png)
52
+ ## Motivation
53
+
54
+ When working with geospatial data, it's common to encounter datasets where geometries (such as lines representing roads or polygons representing land parcels) overlap one another. For many analysis and visualization tasks, it is desirable to remove these overlaps, ensuring that each point in space is covered by at most one geometry. This process, which we call "de-overlapping," can be complex to implement efficiently.
55
+
56
+ This module provides a robust and easy-to-use solution for de-overlapping Shapely geometries. It intelligently handles various geometry types and provides fine-grained control over the output, making it a valuable tool for data cleaning and preparation in geospatial workflows.
57
+
58
+ ## Installation
59
+
60
+ You can install `deoverlap` directly from PyPI using pip:
61
+
62
+ ```bash
63
+ pip install deoverlap
64
+ ```
65
+
66
+ ## How to Use
67
+
68
+ The core of this module is the `deoverlap` function. It takes an iterable of Shapely geometries and a tolerance value as input and returns the de-overlapped geometries.
69
+
70
+ ### Basic Usage: Flat Mode
71
+
72
+ The default mode of operation is the "flat" mode (`preserve_types=False`). This mode is optimized for speed and returns a simple list of `LineString` and `Point` geometries. In this mode, any input `Polygon` objects are converted to their boundary `LineString`.
73
+
74
+ Here's a simple example of de-overlapping two overlapping `LineString`:
75
+
76
+ ```python
77
+ from shapely.geometry import LineString
78
+ from deoverlap import deoverlap
79
+
80
+ geoms = [LineString([(0, 0), (2, 0)]), LineString([(1, 0.05), (3, 0.05)])]
81
+ tolerance = 0.1
82
+
83
+ kept_geoms, removed_geoms, mask = deoverlap(geoms, tolerance)
84
+
85
+ print(f"Kept geometries: {len(kept_geoms)}")
86
+ print(f"Removed geometries: {len(removed_geoms)}")
87
+ ```
88
+
89
+ ## Advanced Usage
90
+
91
+ The `deoverlap` function offers several parameters to control its behavior:
92
+
93
+ - `preserve_types` (`bool`): When `True`, the function will attempt to preserve the original geometry types (e.g., `Polygon`, `MultiLineString`). This is more computationally intensive but provides a more structured output.
94
+ - `keep_duplicates` (`bool`): If `True`, the function will also return the portions of the geometries that were removed due to overlap.
95
+ - `track_origins` (`bool`): Only applicable when `preserve_types=True`. If `True`, the output is a dictionary that maps the removed parts to their original index in the input list. This is useful for understanding which original geometries were affected by the de-overlapping process.
96
+
97
+ ![](test_outputs/test_tangent_circles_and_line_s_T_k_T.png)
98
+ ### Preserving Geometry Types
99
+
100
+ Set `preserve_types=True` to maintain the original geometry types in the output. This is particularly useful when working with Polygons or when you need to maintain the distinction between single and multi-part geometries.
101
+
102
+ **Example:** De-overlapping a `LineString` that intersects another. With `preserve_types=True`, the intersected `LineString` is returned as a `MultiLineString`.
103
+
104
+ - `preserve_types=False` (default, flat output):
105
+ - `preserve_types=True` (structured output):
106
+
107
+ ### Keeping Removed Portions
108
+
109
+ Set `keep_duplicates=True` to get a list of the geometries (or portions of geometries) that were removed.
110
+
111
+ **Example:** Visualizing the removed portion of an engulfed `LineString`.
112
+
113
+ - `keep_duplicates=False` (default):
114
+ - `keep_duplicates=True` (showing removed parts):
115
+
116
+ ### Tracking the Origin of Removed Parts
117
+
118
+ For detailed analysis of which input geometries were modified, use `track_origins=True` in conjunction with `preserve_types=True`. This returns a dictionary containing the kept geometries, a map of removed parts to their original indices, a list of indices of geometries that were wholly removed, and the overlap mask.
119
+
120
+ **Example:**
121
+
122
+ ```python
123
+ from shapely.geometry import LineString
124
+ from deoverlap import deoverlap
125
+
126
+ geoms = [
127
+ LineString([(0, 0), (4, 0)]),
128
+ LineString([(1, 0.05), (2, 0.05)]), # This line will be wholly removed
129
+ LineString([(3, 0.05), (5, 0.05)])
130
+ ]
131
+ tolerance = 0.1
132
+
133
+ results = deoverlap(
134
+ geoms,
135
+ tolerance,
136
+ preserve_types=True,
137
+ keep_duplicates=True,
138
+ track_origins=True
139
+ )
140
+
141
+ print(f"Kept geometries: {len(results['kept'])}")
142
+ print(f"Removed parts map: {results['removed_parts']}")
143
+ print(f"Wholly removed indices: {results['wholly_removed_indices']}")
144
+ ```
145
+
146
+ This will produce the following output, clearly indicating that the geometry at index 1 was completely removed:
147
+
148
+ ```
149
+ Kept geometries: 2
150
+ Removed parts map: {0: [<MultiLineString object>], 1: [<LineString object>], 2: [<LineString object>]}
151
+ Wholly removed indices: [1]
152
+ ```
153
+
154
+ ## Working with Polygons and Mixed Geometries
155
+
156
+ The `deoverlap` function can handle a mix of geometry types, including Polygons. When `preserve_types` is `False`, polygons are treated as their boundary lines. When `preserve_types` is `True`, the function attempts to preserve the `Polygon` objects, clipping them as necessary.
157
+
158
+ **Example:** De-overlapping a set of tangent circles and a line.
159
+
160
+ - `preserve_types=False` (flat output):
161
+ - `preserve_types=True` (structured output):
162
+
163
+ ## API Reference
164
+
165
+ ### `deoverlap(geometries, tolerance, preserve_types=False, keep_duplicates=False, track_origins=False, progress_bar=False)`
166
+
167
+ De-overlaps a list of geometries with extensive options for output format and origin tracking.
168
+
169
+ #### Parameters:
170
+
171
+ - `geometries` (`GeomInput`): An iterable of Shapely geometries.
172
+ - `tolerance` (`float`): The buffer distance to consider geometries as overlapping.
173
+ - `preserve_types` (`bool`, optional):
174
+ - `False` (Default): Fast mode. Returns a flat list of simple `LineString`s and `Point`s.
175
+ - `True`: Powerful mode. Returns structured geometries (e.g., `MultiLineString`). Slower.
176
+ - `keep_duplicates` (`bool`, optional): If `True`, the removed/overlapping portions are returned. Defaults to `False`.
177
+ - `track_origins` (`bool`, optional): Only applies when `preserve_types=True`. If `True`, returns a detailed dictionary mapping removed parts to their original index. Defaults to `False`.
178
+ - `progress_bar` (`bool`, optional): If `True`, displays a tqdm progress bar. Defaults to `False`.
179
+
180
+ #### Returns:
181
+
182
+ - The return type depends on the flags:
183
+ - If `preserve_types=False` (flat mode):
184
+ `(kept_geoms, kept_map, removed_geoms, mask)`
185
+ - `kept_geoms`: List of kept (non-overlapping) geometries (LineString, Point)
186
+ - `kept_map`: Always an empty dict in this mode
187
+ - `removed_geoms`: List of removed (overlapping) geometries (if `keep_duplicates=True`, else empty list)
188
+ - `mask`: List of mask polygons used for clipping
189
+ - If `preserve_types=True` and `track_origins=False` (structured mode):
190
+ `(kept_geoms, kept_map, removed_geoms, mask)`
191
+ - `kept_geoms`: List of kept (non-overlapping) structured geometries
192
+ - `kept_map`: Dict mapping input index to list of kept geometries
193
+ - `removed_geoms`: List of removed (overlapping) geometries (if `keep_duplicates=True`, else empty list)
194
+ - `mask`: List of mask polygons used for clipping
195
+ - If `preserve_types=True` and `track_origins=True` (structured mode with origin tracking):
196
+ A dictionary with keys:
197
+ - `"kept"`: List of kept (non-overlapping) structured geometries
198
+ - `"kept_parts"`: Dict mapping input index to list of kept geometries
199
+ - `"removed_parts"`: Dict mapping input index to list of removed geometries (if `keep_duplicates=True`, else empty dict)
200
+ - `"wholly_removed_indices"`: List of indices of geometries that were wholly removed
201
+ - `"mask"`: List of mask polygons used for clipping
202
+
@@ -0,0 +1,156 @@
1
+ # Deoverlap
2
+
3
+ A high-level Python module for de-overlapping a set of Shapely geometric objects. It offers two main modes of operation: a fast "flat" mode that returns simple geometries, and a more powerful "structured" mode that preserves geometry types and can track the origin of removed pieces.
4
+
5
+ ![](test_outputs/test_tangent_circles_and_line_s_F_k_T.png)
6
+ ## Motivation
7
+
8
+ When working with geospatial data, it's common to encounter datasets where geometries (such as lines representing roads or polygons representing land parcels) overlap one another. For many analysis and visualization tasks, it is desirable to remove these overlaps, ensuring that each point in space is covered by at most one geometry. This process, which we call "de-overlapping," can be complex to implement efficiently.
9
+
10
+ This module provides a robust and easy-to-use solution for de-overlapping Shapely geometries. It intelligently handles various geometry types and provides fine-grained control over the output, making it a valuable tool for data cleaning and preparation in geospatial workflows.
11
+
12
+ ## Installation
13
+
14
+ You can install `deoverlap` directly from PyPI using pip:
15
+
16
+ ```bash
17
+ pip install deoverlap
18
+ ```
19
+
20
+ ## How to Use
21
+
22
+ The core of this module is the `deoverlap` function. It takes an iterable of Shapely geometries and a tolerance value as input and returns the de-overlapped geometries.
23
+
24
+ ### Basic Usage: Flat Mode
25
+
26
+ The default mode of operation is the "flat" mode (`preserve_types=False`). This mode is optimized for speed and returns a simple list of `LineString` and `Point` geometries. In this mode, any input `Polygon` objects are converted to their boundary `LineString`.
27
+
28
+ Here's a simple example of de-overlapping two overlapping `LineString`:
29
+
30
+ ```python
31
+ from shapely.geometry import LineString
32
+ from deoverlap import deoverlap
33
+
34
+ geoms = [LineString([(0, 0), (2, 0)]), LineString([(1, 0.05), (3, 0.05)])]
35
+ tolerance = 0.1
36
+
37
+ kept_geoms, removed_geoms, mask = deoverlap(geoms, tolerance)
38
+
39
+ print(f"Kept geometries: {len(kept_geoms)}")
40
+ print(f"Removed geometries: {len(removed_geoms)}")
41
+ ```
42
+
43
+ ## Advanced Usage
44
+
45
+ The `deoverlap` function offers several parameters to control its behavior:
46
+
47
+ - `preserve_types` (`bool`): When `True`, the function will attempt to preserve the original geometry types (e.g., `Polygon`, `MultiLineString`). This is more computationally intensive but provides a more structured output.
48
+ - `keep_duplicates` (`bool`): If `True`, the function will also return the portions of the geometries that were removed due to overlap.
49
+ - `track_origins` (`bool`): Only applicable when `preserve_types=True`. If `True`, the output is a dictionary that maps the removed parts to their original index in the input list. This is useful for understanding which original geometries were affected by the de-overlapping process.
50
+
51
+ ![](test_outputs/test_tangent_circles_and_line_s_T_k_T.png)
52
+ ### Preserving Geometry Types
53
+
54
+ Set `preserve_types=True` to maintain the original geometry types in the output. This is particularly useful when working with Polygons or when you need to maintain the distinction between single and multi-part geometries.
55
+
56
+ **Example:** De-overlapping a `LineString` that intersects another. With `preserve_types=True`, the intersected `LineString` is returned as a `MultiLineString`.
57
+
58
+ - `preserve_types=False` (default, flat output):
59
+ - `preserve_types=True` (structured output):
60
+
61
+ ### Keeping Removed Portions
62
+
63
+ Set `keep_duplicates=True` to get a list of the geometries (or portions of geometries) that were removed.
64
+
65
+ **Example:** Visualizing the removed portion of an engulfed `LineString`.
66
+
67
+ - `keep_duplicates=False` (default):
68
+ - `keep_duplicates=True` (showing removed parts):
69
+
70
+ ### Tracking the Origin of Removed Parts
71
+
72
+ For detailed analysis of which input geometries were modified, use `track_origins=True` in conjunction with `preserve_types=True`. This returns a dictionary containing the kept geometries, a map of removed parts to their original indices, a list of indices of geometries that were wholly removed, and the overlap mask.
73
+
74
+ **Example:**
75
+
76
+ ```python
77
+ from shapely.geometry import LineString
78
+ from deoverlap import deoverlap
79
+
80
+ geoms = [
81
+ LineString([(0, 0), (4, 0)]),
82
+ LineString([(1, 0.05), (2, 0.05)]), # This line will be wholly removed
83
+ LineString([(3, 0.05), (5, 0.05)])
84
+ ]
85
+ tolerance = 0.1
86
+
87
+ results = deoverlap(
88
+ geoms,
89
+ tolerance,
90
+ preserve_types=True,
91
+ keep_duplicates=True,
92
+ track_origins=True
93
+ )
94
+
95
+ print(f"Kept geometries: {len(results['kept'])}")
96
+ print(f"Removed parts map: {results['removed_parts']}")
97
+ print(f"Wholly removed indices: {results['wholly_removed_indices']}")
98
+ ```
99
+
100
+ This will produce the following output, clearly indicating that the geometry at index 1 was completely removed:
101
+
102
+ ```
103
+ Kept geometries: 2
104
+ Removed parts map: {0: [<MultiLineString object>], 1: [<LineString object>], 2: [<LineString object>]}
105
+ Wholly removed indices: [1]
106
+ ```
107
+
108
+ ## Working with Polygons and Mixed Geometries
109
+
110
+ The `deoverlap` function can handle a mix of geometry types, including Polygons. When `preserve_types` is `False`, polygons are treated as their boundary lines. When `preserve_types` is `True`, the function attempts to preserve the `Polygon` objects, clipping them as necessary.
111
+
112
+ **Example:** De-overlapping a set of tangent circles and a line.
113
+
114
+ - `preserve_types=False` (flat output):
115
+ - `preserve_types=True` (structured output):
116
+
117
+ ## API Reference
118
+
119
+ ### `deoverlap(geometries, tolerance, preserve_types=False, keep_duplicates=False, track_origins=False, progress_bar=False)`
120
+
121
+ De-overlaps a list of geometries with extensive options for output format and origin tracking.
122
+
123
+ #### Parameters:
124
+
125
+ - `geometries` (`GeomInput`): An iterable of Shapely geometries.
126
+ - `tolerance` (`float`): The buffer distance to consider geometries as overlapping.
127
+ - `preserve_types` (`bool`, optional):
128
+ - `False` (Default): Fast mode. Returns a flat list of simple `LineString`s and `Point`s.
129
+ - `True`: Powerful mode. Returns structured geometries (e.g., `MultiLineString`). Slower.
130
+ - `keep_duplicates` (`bool`, optional): If `True`, the removed/overlapping portions are returned. Defaults to `False`.
131
+ - `track_origins` (`bool`, optional): Only applies when `preserve_types=True`. If `True`, returns a detailed dictionary mapping removed parts to their original index. Defaults to `False`.
132
+ - `progress_bar` (`bool`, optional): If `True`, displays a tqdm progress bar. Defaults to `False`.
133
+
134
+ #### Returns:
135
+
136
+ - The return type depends on the flags:
137
+ - If `preserve_types=False` (flat mode):
138
+ `(kept_geoms, kept_map, removed_geoms, mask)`
139
+ - `kept_geoms`: List of kept (non-overlapping) geometries (LineString, Point)
140
+ - `kept_map`: Always an empty dict in this mode
141
+ - `removed_geoms`: List of removed (overlapping) geometries (if `keep_duplicates=True`, else empty list)
142
+ - `mask`: List of mask polygons used for clipping
143
+ - If `preserve_types=True` and `track_origins=False` (structured mode):
144
+ `(kept_geoms, kept_map, removed_geoms, mask)`
145
+ - `kept_geoms`: List of kept (non-overlapping) structured geometries
146
+ - `kept_map`: Dict mapping input index to list of kept geometries
147
+ - `removed_geoms`: List of removed (overlapping) geometries (if `keep_duplicates=True`, else empty list)
148
+ - `mask`: List of mask polygons used for clipping
149
+ - If `preserve_types=True` and `track_origins=True` (structured mode with origin tracking):
150
+ A dictionary with keys:
151
+ - `"kept"`: List of kept (non-overlapping) structured geometries
152
+ - `"kept_parts"`: Dict mapping input index to list of kept geometries
153
+ - `"removed_parts"`: Dict mapping input index to list of removed geometries (if `keep_duplicates=True`, else empty dict)
154
+ - `"wholly_removed_indices"`: List of indices of geometries that were wholly removed
155
+ - `"mask"`: List of mask polygons used for clipping
156
+
@@ -0,0 +1,41 @@
1
+ # pyproject.toml
2
+
3
+ [build-system]
4
+ requires = ["setuptools>=61.0"]
5
+ build-backend = "setuptools.build_meta"
6
+
7
+ [project]
8
+ name = "deoverlap"
9
+ version = "0.1.1"
10
+ authors = [
11
+ { name="Pietro Leoni", email="pietro.leoni@gmail.com" },
12
+ ]
13
+ description = "A high-level toolkit for de-overlapping Shapely geometries."
14
+ readme = "README.md"
15
+ license = { file="LICENSE" }
16
+ requires-python = ">=3.10"
17
+ classifiers = [
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Operating System :: OS Independent",
24
+ "Topic :: Scientific/Engineering :: GIS",
25
+ "Topic :: Utilities",
26
+ ]
27
+
28
+ dependencies = [
29
+ "shapely>=2.0",
30
+ "tqdm"
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/piLeoni/deoverlap"
35
+ "Bug Tracker" = "https://github.com/piLeoni/deoverlap"
36
+
37
+ [project.optional-dependencies]
38
+ test = [
39
+ "pytest",
40
+ "matplotlib"
41
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """
2
+ A Python toolkit for resolving overlaps in 2D vector geometries.
3
+ """
4
+ __version__ = "0.1.1"
5
+
6
+ from .deoverlap import deoverlap, flatten_geometries, GeomInput
@@ -0,0 +1,293 @@
1
+ """
2
+ This module provides high-level functions for de-overlapping a set of Shapely
3
+ geometric objects. It intelligently removes portions of geometries that are
4
+ within a specified tolerance of geometries earlier in the processing order.
5
+
6
+ It offers two main modes of operation: a fast "flat" mode that returns simple
7
+ geometries (Points and LineStrings), and a more powerful "structured" mode that
8
+ preserves geometry types and can track the origin of removed pieces.
9
+ """
10
+
11
+ from typing import List, Union, Iterable, Tuple, Dict, Any
12
+ from shapely import union_all
13
+ from shapely.ops import unary_union
14
+ from shapely.strtree import STRtree
15
+ from tqdm import tqdm
16
+ from shapely.geometry import (
17
+ LineString, Point, MultiLineString, MultiPoint,
18
+ Polygon, MultiPolygon, GeometryCollection
19
+ )
20
+ from shapely.geometry.base import BaseGeometry
21
+
22
+ # =============================================================================
23
+ # Type Aliases
24
+ # =============================================================================
25
+ # Using type aliases for better readability and maintainability of type hints.
26
+ GeomInput = Union[BaseGeometry, Iterable['GeomInput']]
27
+ FlatGeomOutput = List[Union[LineString, Point]]
28
+
29
+ # =============================================================================
30
+ # Helper Function
31
+ # =============================================================================
32
+
33
+ def flatten_geometries(geoms: GeomInput) -> FlatGeomOutput:
34
+ """
35
+ Recursively flattens any geometry input into a flat list of non-empty
36
+ LineStrings and Points.
37
+
38
+ This utility is used to decompose complex geometries into a simple,
39
+ standardized format that the de-overlapping engines can process.
40
+
41
+ Args:
42
+ geoms: A Shapely geometry or a nested iterable of geometries.
43
+
44
+ Returns:
45
+ A flat list of simple Point and LineString geometries. Polygons are
46
+ converted to their exterior and interior boundary LineStrings.
47
+ """
48
+ out = []
49
+ if geoms is None: return out
50
+
51
+ # Use modern pattern matching for clear, type-safe dispatching.
52
+ match geoms:
53
+ case LineString() | Point():
54
+ if not geoms.is_empty: out.append(geoms)
55
+ case MultiLineString() | MultiPoint() | GeometryCollection():
56
+ # Recursively flatten all geometries within a collection.
57
+ for g in geoms.geoms: out.extend(flatten_geometries(g))
58
+ case Polygon():
59
+ # Convert Polygons to their constituent rings (LineStrings).
60
+ if not geoms.is_empty:
61
+ out.append(LineString(geoms.exterior.coords))
62
+ for ring in geoms.interiors: out.append(LineString(ring.coords))
63
+ case MultiPolygon():
64
+ # Handle MultiPolygons by flattening each Polygon individually.
65
+ for poly in geoms.geoms: out.extend(flatten_geometries(poly))
66
+ case list() | tuple() | set():
67
+ # Handle standard iterable types.
68
+ for g in geoms: out.extend(flatten_geometries(g))
69
+ case _:
70
+ # Fallback for any other type that is a valid Shapely geometry
71
+ # but not explicitly listed above. This provides some future-proofing.
72
+ if not isinstance(geoms, BaseGeometry):
73
+ raise TypeError(f"Unsupported geometry type: {type(geoms)}")
74
+ return out
75
+
76
+ # =============================================================================
77
+ # Internal Engine Functions
78
+ # =============================================================================
79
+
80
+ def _deoverlap_flat_engine(
81
+ geometries: GeomInput,
82
+ tolerance: float,
83
+ progress_bar: bool,
84
+ ) -> Tuple[FlatGeomOutput, FlatGeomOutput, list]:
85
+ """
86
+ Internal engine for fast, flat de-overlapping.
87
+
88
+ This function prioritizes performance by working with a flattened list of
89
+ simple geometries. It always computes both the kept and removed portions.
90
+
91
+ Args:
92
+ geometries: The input geometries to de-overlap.
93
+ tolerance: The buffer distance to define the overlap area.
94
+ progress_bar: Whether to display a tqdm progress bar.
95
+
96
+ Returns:
97
+ A tuple containing:
98
+ - A flat list of the kept (non-overlapping) geometries.
99
+ - A flat list of the removed (overlapping) geometries.
100
+ - The list of mask polygons used for clipping.
101
+ """
102
+ mask, flat_geoms, kept_geoms, removed_geoms = [], flatten_geometries(geometries), [], []
103
+
104
+ # A tiny buffer used to resolve floating-point ambiguities. When geometries
105
+ # are perfectly aligned, a simple `.difference()` can be inconsistent.
106
+ # Buffering the clipping mask ensures robust results.
107
+ ROBUSTNESS_BUFFER = 1e-9
108
+
109
+ iterable = tqdm(flat_geoms, desc="De-overlapping (flat)", disable=not progress_bar)
110
+
111
+ for geom in iterable:
112
+ kept_portion = geom
113
+
114
+ # Only perform clipping if a mask has been built up from previous geometries.
115
+ if mask:
116
+ # Use an STRtree for efficient spatial querying of nearby mask polygons.
117
+ # This is much faster than checking against the entire mask every time.
118
+ tree = STRtree(mask)
119
+ if (nearby_indices := tree.query(geom)).size > 0:
120
+ # Create a local mask from only the relevant nearby polygons.
121
+ local_mask = union_all([mask[i] for i in nearby_indices])
122
+ # The core operation: clip the geometry by the buffered local mask.
123
+ kept_portion = geom.difference(local_mask.buffer(ROBUSTNESS_BUFFER))
124
+
125
+ # Add the kept portion to the results and update the master mask for the next iteration.
126
+ if not kept_portion.is_empty:
127
+ kept_geoms.append(kept_portion)
128
+ mask.append(kept_portion.buffer(tolerance))
129
+
130
+ # The removed portion is simply what's left of the original after the difference.
131
+ if not (removed_portion := geom.difference(kept_portion)).is_empty:
132
+ removed_geoms.append(removed_portion)
133
+
134
+ # Return flattened lists, as difference operations can create multi-part geometries.
135
+ return flatten_geometries(kept_geoms), flatten_geometries(removed_geoms), mask
136
+
137
+ def _deoverlap_structured_engine(
138
+ geometries: Iterable[BaseGeometry],
139
+ tolerance: float,
140
+ progress_bar: bool,
141
+ ) -> Tuple[List[BaseGeometry], Dict[int, List[BaseGeometry]], Dict[int, List[BaseGeometry]], List[int], List[Polygon]]:
142
+ """
143
+ Internal engine for structure-preserving de-overlapping with origin tracking.
144
+
145
+ This function is more powerful, preserving geometry types where possible
146
+ and tracking the origin of all kept and removed pieces.
147
+
148
+ Args:
149
+ geometries: The input geometries to de-overlap.
150
+ tolerance: The buffer distance to define the overlap area.
151
+ progress_bar: Whether to display a tqdm progress bar.
152
+
153
+ Returns:
154
+ A tuple containing:
155
+ - A list of the final kept (non-overlapping) structured geometries.
156
+ - A dictionary mapping original index to its list of kept geometries.
157
+ - A dictionary mapping original index to its list of removed parts.
158
+ - A list of indices of geometries that were wholly removed.
159
+ - The list of mask polygons used for clipping.
160
+ """
161
+ kept_results, kept_parts_map, removed_parts_map, wholly_removed_indices, mask = [], {}, {}, [], []
162
+ ROBUSTNESS_BUFFER = 1e-9
163
+
164
+ iterable = tqdm(list(geometries), desc="De-overlapping (structured)", disable=not progress_bar)
165
+
166
+ # Enumerate to get the original index 'i' for origin tracking.
167
+ for i, geom in enumerate(iterable):
168
+ if geom.is_empty: continue
169
+
170
+ # Decompose the current top-level geometry into its constituent primitive
171
+ # parts (e.g., a MultiLineString becomes a list of LineStrings).
172
+ parts_to_process = flatten_geometries(geom)
173
+ if not parts_to_process: continue
174
+
175
+ kept_sub_parts = []
176
+ if not mask:
177
+ # If the mask is empty (i.e., this is the first geometry), keep all parts.
178
+ kept_sub_parts = parts_to_process
179
+ else:
180
+ # Check each constituent part against the cumulative mask.
181
+ tree = STRtree(mask)
182
+ for part in parts_to_process:
183
+ if (nearby_indices := tree.query(part)).size > 0:
184
+ local_mask = union_all([mask[i] for i in nearby_indices])
185
+ if not (kept_part := part.difference(local_mask.buffer(ROBUSTNESS_BUFFER))).is_empty:
186
+ kept_sub_parts.append(kept_part)
187
+ else: # Part is not near any existing mask geometry, so it's kept entirely.
188
+ kept_sub_parts.append(part)
189
+
190
+ # If no sub-parts survived the clipping, the entire original geometry was removed.
191
+ if not kept_sub_parts:
192
+ wholly_removed_indices.append(i)
193
+ # Store the entire original geometry as the "removed part".
194
+ removed_parts_map.setdefault(i, []).append(geom)
195
+ continue
196
+
197
+ # Reassemble the surviving sub-parts into a single, valid geometry.
198
+ # e.g., two LineStrings become one MultiLineString.
199
+ reassembled_kept_geom = unary_union(kept_sub_parts)
200
+
201
+ # This is a crucial check to preserve Polygons. If a Polygon was clipped
202
+ # but its boundary remains a single, intact ring, we restore the original
203
+ # Polygon object instead of just returning its boundary line.
204
+ final_kept_geom = reassembled_kept_geom
205
+ if geom.geom_type == 'Polygon' and reassembled_kept_geom.equals(geom.boundary):
206
+ final_kept_geom = geom
207
+
208
+ # Store the final kept geometry in both the simple list and the origin-tracked map.
209
+ kept_results.append(final_kept_geom)
210
+ kept_parts_map.setdefault(i, []).append(final_kept_geom)
211
+
212
+ # Calculate the removed portion for origin tracking.
213
+ # For Polygons, we must diff against its boundary, not its area, to get
214
+ # the removed LineString fragments correctly.
215
+ source_for_diff = geom.boundary if isinstance(geom, (Polygon, MultiPolygon)) else geom
216
+ if not (removed_portion := source_for_diff.difference(reassembled_kept_geom)).is_empty:
217
+ removed_parts_map.setdefault(i, []).append(removed_portion)
218
+
219
+ # Update the master mask with the buffer of the geometry that was *actually kept*.
220
+ mask.append(reassembled_kept_geom.buffer(tolerance))
221
+
222
+ return kept_results, kept_parts_map, removed_parts_map, wholly_removed_indices, mask
223
+
224
+ # =============================================================================
225
+ # Single Public-Facing Function
226
+ # =============================================================================
227
+
228
+ def deoverlap(
229
+ geometries: GeomInput,
230
+ tolerance: float,
231
+ preserve_types: bool = False,
232
+ keep_duplicates: bool = False,
233
+ track_origins: bool = False,
234
+ progress_bar: bool = False,
235
+ ) -> Any:
236
+ """De-overlaps a list of geometries, with extensive options for output format.
237
+
238
+ This is the main public-facing function that acts as a dispatcher to the
239
+ internal engines based on user-selected flags.
240
+
241
+ Args:
242
+ geometries: An iterable of shapely geometries.
243
+ tolerance: The buffer distance to consider geometries as overlapping.
244
+ preserve_types (bool, optional):
245
+ - `False` (Default): Fast mode. Returns a flat list of simple
246
+ LineStrings and Points.
247
+ - `True`: Powerful mode. Returns structured geometries
248
+ (e.g., MultiLineString). Slower but more informative.
249
+ keep_duplicates (bool, optional):
250
+ If `True`, the removed/overlapping portions are also returned.
251
+ Defaults to False.
252
+ track_origins (bool, optional):
253
+ Only applies when `preserve_types=True`. If `True`, returns a
254
+ detailed dictionary with full origin tracking. Defaults to False.
255
+ progress_bar (bool, optional):
256
+ If `True`, displays a tqdm progress bar during processing.
257
+ Defaults to False.
258
+
259
+ Returns:
260
+ The return type is dynamic and depends on the flags:
261
+ - `preserve_types=False`:
262
+ `(kept_geoms, kept_map, removed_geoms, mask)` where `kept_map` is empty.
263
+ - `preserve_types=True` and `track_origins=False`:
264
+ `(kept_geoms, kept_map, removed_geoms, mask)`
265
+ - `preserve_types=True` and `track_origins=True`:
266
+ A dictionary with keys `("kept", "kept_parts", "removed_parts",
267
+ "wholly_removed_indices", "mask")`.
268
+ """
269
+ # --- Mode 1: Fast, Flat Output ---
270
+ if not preserve_types:
271
+ kept, removed, mask = _deoverlap_flat_engine(geometries, tolerance, progress_bar)
272
+ # Return a consistent 4-item tuple for predictable unpacking in tests.
273
+ return kept, {}, (removed if keep_duplicates else []), mask
274
+
275
+ # --- Mode 2: Structure-Preserving Output ---
276
+ kept, kept_map, removed_map, wholly_removed, mask = _deoverlap_structured_engine(geometries, tolerance, progress_bar)
277
+
278
+ # Sub-mode: Return the detailed dictionary with full origin tracking.
279
+ if track_origins:
280
+ return {
281
+ "kept": kept,
282
+ "kept_parts": kept_map,
283
+ "removed_parts": (removed_map if keep_duplicates else {}),
284
+ "wholly_removed_indices": wholly_removed,
285
+ "mask": mask
286
+ }
287
+ else:
288
+ # Sub-mode: Return a simplified tuple for compatibility.
289
+ removed_list = []
290
+ if keep_duplicates:
291
+ for parts in removed_map.values():
292
+ removed_list.extend(parts)
293
+ return kept, kept_map, removed_list, mask
@@ -0,0 +1,202 @@
1
+ Metadata-Version: 2.4
2
+ Name: deoverlap
3
+ Version: 0.1.1
4
+ Summary: A high-level toolkit for de-overlapping Shapely geometries.
5
+ Author-email: Pietro Leoni <pietro.leoni@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) [2025] [Pietro Leoni]
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ Project-URL: Homepage, https://github.com/piLeoni/deoverlap
28
+ Project-URL: Bug Tracker, https://github.com/piLeoni/deoverlap
29
+ Classifier: Programming Language :: Python :: 3
30
+ Classifier: Programming Language :: Python :: 3.10
31
+ Classifier: Programming Language :: Python :: 3.11
32
+ Classifier: Programming Language :: Python :: 3.12
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Operating System :: OS Independent
35
+ Classifier: Topic :: Scientific/Engineering :: GIS
36
+ Classifier: Topic :: Utilities
37
+ Requires-Python: >=3.10
38
+ Description-Content-Type: text/markdown
39
+ License-File: LICENSE
40
+ Requires-Dist: shapely>=2.0
41
+ Requires-Dist: tqdm
42
+ Provides-Extra: test
43
+ Requires-Dist: pytest; extra == "test"
44
+ Requires-Dist: matplotlib; extra == "test"
45
+ Dynamic: license-file
46
+
47
+ # Deoverlap
48
+
49
+ A high-level Python module for de-overlapping a set of Shapely geometric objects. It offers two main modes of operation: a fast "flat" mode that returns simple geometries, and a more powerful "structured" mode that preserves geometry types and can track the origin of removed pieces.
50
+
51
+ ![](test_outputs/test_tangent_circles_and_line_s_F_k_T.png)
52
+ ## Motivation
53
+
54
+ When working with geospatial data, it's common to encounter datasets where geometries (such as lines representing roads or polygons representing land parcels) overlap one another. For many analysis and visualization tasks, it is desirable to remove these overlaps, ensuring that each point in space is covered by at most one geometry. This process, which we call "de-overlapping," can be complex to implement efficiently.
55
+
56
+ This module provides a robust and easy-to-use solution for de-overlapping Shapely geometries. It intelligently handles various geometry types and provides fine-grained control over the output, making it a valuable tool for data cleaning and preparation in geospatial workflows.
57
+
58
+ ## Installation
59
+
60
+ You can install `deoverlap` directly from PyPI using pip:
61
+
62
+ ```bash
63
+ pip install deoverlap
64
+ ```
65
+
66
+ ## How to Use
67
+
68
+ The core of this module is the `deoverlap` function. It takes an iterable of Shapely geometries and a tolerance value as input and returns the de-overlapped geometries.
69
+
70
+ ### Basic Usage: Flat Mode
71
+
72
+ The default mode of operation is the "flat" mode (`preserve_types=False`). This mode is optimized for speed and returns a simple list of `LineString` and `Point` geometries. In this mode, any input `Polygon` objects are converted to their boundary `LineString`.
73
+
74
+ Here's a simple example of de-overlapping two overlapping `LineString`:
75
+
76
+ ```python
77
+ from shapely.geometry import LineString
78
+ from deoverlap import deoverlap
79
+
80
+ geoms = [LineString([(0, 0), (2, 0)]), LineString([(1, 0.05), (3, 0.05)])]
81
+ tolerance = 0.1
82
+
83
+ kept_geoms, removed_geoms, mask = deoverlap(geoms, tolerance)
84
+
85
+ print(f"Kept geometries: {len(kept_geoms)}")
86
+ print(f"Removed geometries: {len(removed_geoms)}")
87
+ ```
88
+
89
+ ## Advanced Usage
90
+
91
+ The `deoverlap` function offers several parameters to control its behavior:
92
+
93
+ - `preserve_types` (`bool`): When `True`, the function will attempt to preserve the original geometry types (e.g., `Polygon`, `MultiLineString`). This is more computationally intensive but provides a more structured output.
94
+ - `keep_duplicates` (`bool`): If `True`, the function will also return the portions of the geometries that were removed due to overlap.
95
+ - `track_origins` (`bool`): Only applicable when `preserve_types=True`. If `True`, the output is a dictionary that maps the removed parts to their original index in the input list. This is useful for understanding which original geometries were affected by the de-overlapping process.
96
+
97
+ ![](test_outputs/test_tangent_circles_and_line_s_T_k_T.png)
98
+ ### Preserving Geometry Types
99
+
100
+ Set `preserve_types=True` to maintain the original geometry types in the output. This is particularly useful when working with Polygons or when you need to maintain the distinction between single and multi-part geometries.
101
+
102
+ **Example:** De-overlapping a `LineString` that intersects another. With `preserve_types=True`, the intersected `LineString` is returned as a `MultiLineString`.
103
+
104
+ - `preserve_types=False` (default, flat output):
105
+ - `preserve_types=True` (structured output):
106
+
107
+ ### Keeping Removed Portions
108
+
109
+ Set `keep_duplicates=True` to get a list of the geometries (or portions of geometries) that were removed.
110
+
111
+ **Example:** Visualizing the removed portion of an engulfed `LineString`.
112
+
113
+ - `keep_duplicates=False` (default):
114
+ - `keep_duplicates=True` (showing removed parts):
115
+
116
+ ### Tracking the Origin of Removed Parts
117
+
118
+ For detailed analysis of which input geometries were modified, use `track_origins=True` in conjunction with `preserve_types=True`. This returns a dictionary containing the kept geometries, a map of removed parts to their original indices, a list of indices of geometries that were wholly removed, and the overlap mask.
119
+
120
+ **Example:**
121
+
122
+ ```python
123
+ from shapely.geometry import LineString
124
+ from deoverlap import deoverlap
125
+
126
+ geoms = [
127
+ LineString([(0, 0), (4, 0)]),
128
+ LineString([(1, 0.05), (2, 0.05)]), # This line will be wholly removed
129
+ LineString([(3, 0.05), (5, 0.05)])
130
+ ]
131
+ tolerance = 0.1
132
+
133
+ results = deoverlap(
134
+ geoms,
135
+ tolerance,
136
+ preserve_types=True,
137
+ keep_duplicates=True,
138
+ track_origins=True
139
+ )
140
+
141
+ print(f"Kept geometries: {len(results['kept'])}")
142
+ print(f"Removed parts map: {results['removed_parts']}")
143
+ print(f"Wholly removed indices: {results['wholly_removed_indices']}")
144
+ ```
145
+
146
+ This will produce the following output, clearly indicating that the geometry at index 1 was completely removed:
147
+
148
+ ```
149
+ Kept geometries: 2
150
+ Removed parts map: {0: [<MultiLineString object>], 1: [<LineString object>], 2: [<LineString object>]}
151
+ Wholly removed indices: [1]
152
+ ```
153
+
154
+ ## Working with Polygons and Mixed Geometries
155
+
156
+ The `deoverlap` function can handle a mix of geometry types, including Polygons. When `preserve_types` is `False`, polygons are treated as their boundary lines. When `preserve_types` is `True`, the function attempts to preserve the `Polygon` objects, clipping them as necessary.
157
+
158
+ **Example:** De-overlapping a set of tangent circles and a line.
159
+
160
+ - `preserve_types=False` (flat output):
161
+ - `preserve_types=True` (structured output):
162
+
163
+ ## API Reference
164
+
165
+ ### `deoverlap(geometries, tolerance, preserve_types=False, keep_duplicates=False, track_origins=False, progress_bar=False)`
166
+
167
+ De-overlaps a list of geometries with extensive options for output format and origin tracking.
168
+
169
+ #### Parameters:
170
+
171
+ - `geometries` (`GeomInput`): An iterable of Shapely geometries.
172
+ - `tolerance` (`float`): The buffer distance to consider geometries as overlapping.
173
+ - `preserve_types` (`bool`, optional):
174
+ - `False` (Default): Fast mode. Returns a flat list of simple `LineString`s and `Point`s.
175
+ - `True`: Powerful mode. Returns structured geometries (e.g., `MultiLineString`). Slower.
176
+ - `keep_duplicates` (`bool`, optional): If `True`, the removed/overlapping portions are returned. Defaults to `False`.
177
+ - `track_origins` (`bool`, optional): Only applies when `preserve_types=True`. If `True`, returns a detailed dictionary mapping removed parts to their original index. Defaults to `False`.
178
+ - `progress_bar` (`bool`, optional): If `True`, displays a tqdm progress bar. Defaults to `False`.
179
+
180
+ #### Returns:
181
+
182
+ - The return type depends on the flags:
183
+ - If `preserve_types=False` (flat mode):
184
+ `(kept_geoms, kept_map, removed_geoms, mask)`
185
+ - `kept_geoms`: List of kept (non-overlapping) geometries (LineString, Point)
186
+ - `kept_map`: Always an empty dict in this mode
187
+ - `removed_geoms`: List of removed (overlapping) geometries (if `keep_duplicates=True`, else empty list)
188
+ - `mask`: List of mask polygons used for clipping
189
+ - If `preserve_types=True` and `track_origins=False` (structured mode):
190
+ `(kept_geoms, kept_map, removed_geoms, mask)`
191
+ - `kept_geoms`: List of kept (non-overlapping) structured geometries
192
+ - `kept_map`: Dict mapping input index to list of kept geometries
193
+ - `removed_geoms`: List of removed (overlapping) geometries (if `keep_duplicates=True`, else empty list)
194
+ - `mask`: List of mask polygons used for clipping
195
+ - If `preserve_types=True` and `track_origins=True` (structured mode with origin tracking):
196
+ A dictionary with keys:
197
+ - `"kept"`: List of kept (non-overlapping) structured geometries
198
+ - `"kept_parts"`: Dict mapping input index to list of kept geometries
199
+ - `"removed_parts"`: Dict mapping input index to list of removed geometries (if `keep_duplicates=True`, else empty dict)
200
+ - `"wholly_removed_indices"`: List of indices of geometries that were wholly removed
201
+ - `"mask"`: List of mask polygons used for clipping
202
+
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/deoverlap/__init__.py
5
+ src/deoverlap/deoverlap.py
6
+ src/deoverlap.egg-info/PKG-INFO
7
+ src/deoverlap.egg-info/SOURCES.txt
8
+ src/deoverlap.egg-info/dependency_links.txt
9
+ src/deoverlap.egg-info/requires.txt
10
+ src/deoverlap.egg-info/top_level.txt
11
+ tests/test_deoverlap.py
@@ -0,0 +1,6 @@
1
+ shapely>=2.0
2
+ tqdm
3
+
4
+ [test]
5
+ pytest
6
+ matplotlib
@@ -0,0 +1 @@
1
+ deoverlap
@@ -0,0 +1,257 @@
1
+ import os
2
+ import inspect
3
+ import pytest
4
+ from typing import Union, Iterable
5
+ from itertools import cycle
6
+ import matplotlib.pyplot as plt
7
+ import numpy as np
8
+ from shapely import union_all
9
+ from shapely.geometry import (LineString, Point, MultiLineString,
10
+ Polygon, MultiPolygon, GeometryCollection)
11
+ from shapely.geometry.base import BaseGeometry
12
+
13
+ from deoverlap import deoverlap, flatten_geometries
14
+
15
+ GeomInput = Union[BaseGeometry, Iterable['GeomInput']]
16
+
17
+ # =============================================================================
18
+ # Global Color Configuration
19
+ # =============================================================================
20
+
21
+ # Colors for the default "flat" mode plots
22
+ FLAT_MODE_COLORS = {
23
+ "kept": "#0A8AB2",
24
+ "removed": "#FF0249",
25
+ "mask": "#F26835",
26
+ "background": "#F2F2F2"
27
+ }
28
+
29
+ # Default color palette for "structured" (preserve_types=True) mode plots.
30
+ # This list will be cycled through for each original geometry.
31
+ STRUCTURED_COLOR_PALETTE = [
32
+ '#2DA6CC', '#0A8AB2', '#52b69a', '#FFF029', '#C0D99A',
33
+ '#1A1F4A', '#72588C', '#D98299', '#7CA687', '#314022'
34
+ ]
35
+
36
+
37
+ # =============================================================================
38
+ # Test Visualization Helper (Using Global Colors)
39
+ # =============================================================================
40
+ def plot_results(input_geoms: GeomInput, kept_items: dict, removed_items: Union[list, dict], mask: list, filename: str = None, title: str = None, folder: str = 'test_outputs', color_palette: list = None):
41
+ """
42
+ Generates and saves a plot visualizing the results of the deoverlap process.
43
+ Uses global color variables for styling.
44
+ """
45
+ # --- Filename and Title Setup (unchanged) ---
46
+ if filename is None:
47
+ test_name = "unknown_test"
48
+ try:
49
+ stack_frame = inspect.stack()[1]
50
+ test_name = stack_frame.function
51
+ if color_palette:
52
+ test_name += "_custom_palette"
53
+ params = stack_frame[0].f_locals
54
+ param_str_list = []
55
+ if 'preserve_types' in params: param_str_list.append(f"s_{str(params['preserve_types'])[0]}")
56
+ if 'keep_duplicates' in params: param_str_list.append(f"k_{str(params['keep_duplicates'])[0]}")
57
+ if 'track_origins' in params: param_str_list.append(f"o_{str(params['track_origins'])[0]}")
58
+ filename = f"{test_name}_{'_'.join(param_str_list)}" if param_str_list else test_name
59
+ except (KeyError, IndexError):
60
+ filename = test_name
61
+ if title is None:
62
+ try:
63
+ test_name = inspect.stack()[1].function
64
+ main_title_line = test_name.replace('_', ' ').capitalize()
65
+ params = inspect.stack()[1][0].f_locals
66
+ param_lines = []
67
+ if 'preserve_types' in params: param_lines.append(f"preserve_types: {params.get('preserve_types')}")
68
+ if 'keep_duplicates' in params: param_lines.append(f"keep_duplicates: {params.get('keep_duplicates')}")
69
+ if 'track_origins' in params: param_lines.append(f"track_origins: {params.get('track_origins', False)}")
70
+ title = main_title_line
71
+ if param_lines:
72
+ title += "\n\n" + "\n".join(param_lines)
73
+ except (KeyError, IndexError):
74
+ title = filename.replace('_', ' ').capitalize()
75
+
76
+ # --- Plotting Setup ---
77
+ os.makedirs(folder, exist_ok=True)
78
+ fig, ax = plt.subplots(figsize=(8, 8), constrained_layout=True)
79
+ labels_used = set()
80
+ params = inspect.stack()[1][0].f_locals
81
+ input_geoms_list = list(input_geoms)
82
+ LEGEND_THRESHOLD = 20
83
+ enable_legend = len(input_geoms_list) <= LEGEND_THRESHOLD
84
+ def add_to_legend(label):
85
+ if enable_legend and label not in labels_used:
86
+ labels_used.add(label)
87
+ return label
88
+ return ""
89
+
90
+ # --- Set plot bounds (unchanged) ---
91
+ if flat_inputs := flatten_geometries(input_geoms_list):
92
+ all_bounds = [g.bounds for g in flat_inputs if not g.is_empty]
93
+ if all_bounds:
94
+ min_x, min_y, max_x, max_y = (min(b[0] for b in all_bounds), min(b[1] for b in all_bounds),
95
+ max(b[2] for b in all_bounds), max(b[3] for b in all_bounds))
96
+ width, height = max_x - min_x, max_y - min_y
97
+ max_dim = max(width, height) if max(width, height) > 0 else 1
98
+ padding = max_dim * 0.1
99
+ center_x, center_y = (min_x + max_x) / 2, (min_y + max_y) / 2
100
+ half_size = (max_dim / 2) + padding
101
+ ax.set_xlim(center_x - half_size, center_x + half_size)
102
+ ax.set_ylim(center_y - half_size, center_y + half_size)
103
+
104
+ # --- Draw geometries using global colors ---
105
+ active_palette = color_palette if color_palette is not None else STRUCTURED_COLOR_PALETTE
106
+ color_cycler = cycle(active_palette)
107
+ structured_colors = {i: next(color_cycler) for i in range(len(input_geoms_list))}
108
+
109
+ if mask and not (full_mask := union_all(mask)).is_empty:
110
+ for poly in getattr(full_mask, 'geoms', [full_mask]):
111
+ if isinstance(poly, Polygon):
112
+ ax.plot(*poly.exterior.xy, color=FLAT_MODE_COLORS["mask"], linewidth=0.5, label=add_to_legend('Mask Outline'), zorder=1)
113
+ for interior in poly.interiors:
114
+ ax.plot(*interior.xy, color=FLAT_MODE_COLORS["mask"], linewidth=0.5, zorder=1)
115
+
116
+ for g in flatten_geometries(input_geoms_list):
117
+ if g.geom_type == 'LineString':
118
+ ax.plot(*g.xy, color=FLAT_MODE_COLORS["background"], linewidth=5, alpha=0.6, zorder=2, solid_capstyle='round')
119
+ elif g.geom_type == 'Point':
120
+ ax.plot(g.x, g.y, 'o', color=FLAT_MODE_COLORS["background"], markersize=15, alpha=0.6, zorder=2)
121
+
122
+ # --- Plot Kept Items ---
123
+ if params.get('preserve_types'):
124
+ for i, kept_parts in kept_items.items():
125
+ color = structured_colors.get(i)
126
+ for k_geom in flatten_geometries(kept_parts):
127
+ if k_geom.geom_type == 'LineString':
128
+ ax.plot(*k_geom.xy, color=color, linewidth=2.5, label=add_to_legend(f'Kept from {i}'), zorder=4, solid_capstyle='round')
129
+ elif k_geom.geom_type == 'Point':
130
+ ax.plot(k_geom.x, k_geom.y, 'o', color=color, markersize=8, label=add_to_legend(f'Kept from {i}'), zorder=5)
131
+ else: # Flat mode
132
+ flat_kept = list(kept_items.values())[0]
133
+ for g in flatten_geometries(flat_kept):
134
+ if g.geom_type == 'LineString':
135
+ ax.plot(*g.xy, color=FLAT_MODE_COLORS["kept"], linewidth=2.5, label=add_to_legend('Kept'), zorder=4, alpha=0.75, solid_capstyle='round')
136
+ elif g.geom_type == 'Point':
137
+ ax.plot(g.x, g.y, 'o', color=FLAT_MODE_COLORS["kept"], markersize=8, label=add_to_legend('Kept'), zorder=5)
138
+
139
+ # --- Plot Removed Items ---
140
+ if isinstance(removed_items, dict): # track_origins=True case
141
+ for i, removed_parts_list in removed_items.items():
142
+ color = structured_colors.get(i)
143
+ for r_geom in flatten_geometries(removed_parts_list):
144
+ if r_geom.geom_type == 'LineString':
145
+ ax.plot(*r_geom.xy, color=color, linewidth=2.0, linestyle='--', label=add_to_legend(f'Removed from {i}'), zorder=3, solid_capstyle='round')
146
+ elif r_geom.geom_type == 'Point':
147
+ ax.plot(r_geom.x, r_geom.y, 'x', color=color, markersize=10, mew=2.5, label=add_to_legend(f'Removed from {i}'), zorder=4)
148
+ elif isinstance(removed_items, list): # All other cases
149
+ color = FLAT_MODE_COLORS["removed"]
150
+ if params.get('preserve_types'):
151
+ pass
152
+ for g in flatten_geometries(removed_items):
153
+ if g.geom_type == 'LineString':
154
+ ax.plot(*g.xy, color=color, linewidth=2.5, label=add_to_legend('Removed'), zorder=3, solid_capstyle='round')
155
+ elif g.geom_type == 'Point':
156
+ ax.plot(g.x, g.y, 'x', color=color, markersize=10, mew=2.5, label=add_to_legend('Removed'), zorder=4)
157
+
158
+ # --- Finalize plot ---
159
+ if title:
160
+ ax.set_title(title, fontsize=10, pad=20, loc='center')
161
+ ax.set_aspect('equal', adjustable='box')
162
+ ax.axis('off')
163
+ if labels_used:
164
+ ax.legend(loc='lower right')
165
+ plt.savefig(os.path.join(folder, f"{filename}.png"), dpi=150)
166
+ plt.close(fig)
167
+
168
+
169
+ # =============================================================================
170
+ # Comprehensive Test Suite (Unchanged)
171
+ # =============================================================================
172
+
173
+ @pytest.mark.parametrize("preserve_types", [True, False])
174
+ @pytest.mark.parametrize("keep_duplicates", [True, False])
175
+ def test_simple_line_overlap(preserve_types, keep_duplicates):
176
+ geoms = [LineString([(0, 0), (2, 0)]), LineString([(1, 0.05), (3, 0.05)])]
177
+ kept, kept_map, removed, mask = deoverlap(geoms, 0.1, preserve_types, keep_duplicates)
178
+ plot_results(geoms, kept_map if preserve_types else {0: kept}, removed, mask)
179
+ if preserve_types: assert len(kept) == 2
180
+ else: assert len(kept) == 2
181
+ if keep_duplicates: assert len(removed) > 0
182
+ else: assert len(removed) == 0
183
+
184
+ @pytest.mark.parametrize("preserve_types", [True, False])
185
+ @pytest.mark.parametrize("keep_duplicates", [True, False])
186
+ def test_line_fully_engulfed(preserve_types, keep_duplicates):
187
+ geoms = [LineString([(0, 0), (3, 0)]), LineString([(1, 0), (2, 0)])]
188
+ kept, kept_map, removed, mask = deoverlap(geoms, 0.2, preserve_types, keep_duplicates)
189
+ plot_results(geoms, kept_map if preserve_types else {0: kept}, removed, mask)
190
+ assert len(kept) == 1
191
+ if keep_duplicates: assert len(removed) == 1
192
+ else: assert len(removed) == 0
193
+
194
+ @pytest.mark.parametrize("preserve_types", [True, False])
195
+ @pytest.mark.parametrize("keep_duplicates", [True, False])
196
+ def test_complex_intersection(preserve_types, keep_duplicates):
197
+ geoms = [LineString([(0, 1), (3, 1)]), LineString([(1.5, 0), (1.5, 2)])]
198
+ kept, kept_map, removed, mask = deoverlap(geoms, 0.2, preserve_types, keep_duplicates)
199
+ plot_results(geoms, kept_map if preserve_types else {0: kept}, removed, mask)
200
+ if preserve_types: assert len(kept) == 2
201
+ else: assert len(kept) == 3
202
+ if keep_duplicates: assert len(removed) == 1
203
+ else: assert len(removed) == 0
204
+
205
+ @pytest.mark.parametrize("preserve_types", [True, False])
206
+ @pytest.mark.parametrize("keep_duplicates", [True, False])
207
+ def test_tangent_circles_and_line(preserve_types, keep_duplicates):
208
+ geoms = [ Point(0, 0).buffer(1.5), Point(2.0, 0).buffer(0.5), Point(0, 2.5).buffer(1.0), LineString([(-2, 1), (3, 1)]) ]
209
+ kept, kept_map, removed, mask = deoverlap(geoms, 0.1, preserve_types, keep_duplicates)
210
+ plot_results(geoms, kept_map if preserve_types else {0: kept}, removed, mask)
211
+ if preserve_types: assert len(kept) == 4
212
+ else: assert len(kept) > 4
213
+ if keep_duplicates: assert len(removed) > 0
214
+ else: assert len(removed) == 0
215
+
216
+ @pytest.mark.parametrize("preserve_types", [True, False])
217
+ @pytest.mark.parametrize("keep_duplicates", [True, False])
218
+ def test_high_volume_points(preserve_types, keep_duplicates):
219
+ points = [Point(x*0.2, y*0.2) for x in range(10) for y in range(10)]
220
+ remover_poly = Polygon([(0.5, 0.5), (0.5, 1.5), (1.5, 1.5), (1.5, 0.5)])
221
+ geoms = [remover_poly] + points
222
+ kept, kept_map, removed, mask = deoverlap(geoms, 0.1, preserve_types, keep_duplicates)
223
+ plot_results(geoms, kept_map if preserve_types else {0: kept}, removed, mask)
224
+ kept_points = [g for g in flatten_geometries(kept) if isinstance(g, Point)]
225
+ removed_points = [g for g in flatten_geometries(removed) if isinstance(g, Point)]
226
+ if keep_duplicates: assert len(kept_points) + len(removed_points) == len(points)
227
+ else: assert len(removed) == 0 and len(kept_points) < len(points)
228
+
229
+ def test_origin_tracking_feature():
230
+ geoms = [ LineString([(0, 0), (4, 0)]), LineString([(1, 0.05), (2, 0.05)]), LineString([(3, 0.05), (5, 0.05)]) ]
231
+ results = deoverlap(geoms, 0.1, preserve_types=True, keep_duplicates=True, track_origins=True)
232
+ plot_results(geoms, results["kept_parts"], results["removed_parts"], results["mask"])
233
+ assert isinstance(results, dict)
234
+ assert len(results["kept"]) == 2
235
+ assert results["wholly_removed_indices"] == [1]
236
+
237
+ @pytest.mark.parametrize("preserve_types", [True, False])
238
+ @pytest.mark.parametrize("keep_duplicates", [True, False])
239
+ def test_tangent_circles(preserve_types, keep_duplicates):
240
+ geoms = [ Point(0, 1).buffer(1), Point(0, 2).buffer(2), Point(0, 3).buffer(3) ]
241
+ kept, kept_map, removed, mask = deoverlap(geoms, 0.1, preserve_types, keep_duplicates)
242
+ plot_results(geoms, kept_map if preserve_types else {0: kept}, removed, mask)
243
+ if preserve_types: assert len(kept) == 3
244
+ else: assert len(kept) > 3
245
+ if keep_duplicates: assert len(removed) > 0
246
+ else: assert len(removed) == 0
247
+
248
+ @pytest.mark.parametrize("preserve_types", [True, False])
249
+ def test_empty_input(preserve_types):
250
+ output = deoverlap([], 0.1, preserve_types=preserve_types, track_origins=True)
251
+ if preserve_types:
252
+ assert isinstance(output, dict) and not output["kept"] and not output["removed_parts"]
253
+ else:
254
+ assert isinstance(output, tuple) and len(output) == 4
255
+ assert output[0] == []
256
+ assert output[1] == {}
257
+ assert output[2] == []