Repository navigation
Expand file tree
/
Copy pathFixMap.py
More file actions
1441 lines (1351 loc) · 71 KB
/
Copy pathFixMap.py
File metadata and controls
1441 lines (1351 loc) · 71 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: utf-8 -*-
"""
Fix Map: make polygons and faults topologically consistent.
Digitised geological maps usually contain small errors: gaps and overlaps between polygons,
shared boundaries whose nodes do not match, triple junctions made of two or three nodes that
almost coincide, faults that run almost (but not exactly) along a contact, doubled features and
polygons lying inside another polygon without a hole. ``fix_map`` removes them within a
tolerance given in metres. The whole process is described step by step in docs/FixMap.md; in
short (geometry is handled as a flat stream of coordinates, all vectorised):
1. Repair invalid polygons (buffer(0), make_valid as a fallback).
2. Remove exact duplicate polygons / faults.
3. A polygon at least 90% inside a bigger polygon gets cut out of it as a hole; then every enclosed gap is
filled with the neighbour that shares the longest boundary (gaps first, so the snapping has less to do).
4. Conform (``_conform``). Every path (polygon ring or fault) has a rank: polygons before faults, bigger
before smaller, longer faults before shorter. A vertex within the tolerance of a higher-ranked line moves
onto its nearest node, or onto the nearest point of its segment (which becomes a node); a higher-ranked
vertex within the tolerance of a lower-ranked segment is inserted into it. Segments are handled once, not
once per polygon, so shared boundaries stay shared. No vertex ends up more than the tolerance from where
it started. Passes repeat until nothing changes (after the first, only around what changed, with a final
full check).
5. Rounds: the geometries are rebuilt, the polygons that changed are checked (repair, holes for contained
polygons, thin overlaps cut from the smaller polygon) and the conform repeats.
6. Once settled (or out of rounds): the remaining overlaps are cut out of one polygon, and any new gaps are
filled; another conform follows.
7. Nodes are added where lines cross (polygon boundaries and faults), then a local conform; anything still
self-intersecting is repaired.
8. A text report lists what was found before and what remains.
"""
import time
import numpy as np
import pandas as pd
import geopandas as gpd
import shapely
from .Densify import _layer_paths, densify_paths, check_same_crs, M_PER_DEG_LAT, M_PER_DEG_LON_AT_EQUATOR
BIG = np.iinfo(np.int64).max // 4
# =============================================================================
# SCALING TO METRES
# =============================================================================
def _metric_scale(layers, geographic):
"""Factors converting CRS units to metres (approximate for degrees)."""
if not geographic:
return np.array([1.0, 1.0])
b = np.array([g.total_bounds for g in layers if len(g)])
lat = 0.5 * (b[:, 1].min() + b[:, 3].max())
return np.array([M_PER_DEG_LON_AT_EQUATOR * np.cos(np.radians(lat)), M_PER_DEG_LAT])
def _kind(gdf):
t = shapely.get_type_id(gdf.geometry.values)
t = t[~shapely.is_missing(gdf.geometry.values)]
if len(t) and np.isin(t, (3, 6)).any():
return "polygon"
if len(t) and np.isin(t, (1, 2, 5)).any():
return "line"
return "none"
# =============================================================================
# STEPS 1-3: REPAIR, DUPLICATES, POLYGONS INSIDE POLYGONS
# =============================================================================
_OVERLAY_FAILS = [] # overlay operations GEOS could not do (reset at the start of every fix_map)
def _grid(gdf=None, bounds=None):
"""Snap-rounding grid for the fallback overlay: 1e-9 degrees, or 1e-6 in projected units."""
if gdf is not None and gdf.crs is not None:
return 1e-9 if gdf.crs.is_geographic else 1e-6
b = np.asarray(bounds if bounds is not None else gdf.total_bounds, dtype=float)
return 1e-9 if np.nanmax(np.abs(b)) <= 360 else 1e-6
def _robust(op, a, b, grid, what=""):
"""
``op(a, b)`` (shapely.intersection / difference / union) that survives GEOS TopologyExceptions on nearly degenerate
input: it is retried on repaired geometry, then with snap-rounding. Returns None (and records ``what``) if it still fails.
"""
for attempt in range(3):
try:
if attempt == 0:
return op(a, b)
if attempt == 1:
return op(shapely.make_valid(a), shapely.make_valid(b))
return op(a, b, grid_size=grid)
except shapely.errors.GEOSException:
continue
_OVERLAY_FAILS.append(str(what))
return None
def _robust_union_all(arr, grid, what=""):
for attempt in range(3):
try:
if attempt == 0:
return shapely.union_all(arr)
if attempt == 1:
return shapely.union_all(shapely.make_valid(arr))
return shapely.union_all(arr, grid_size=grid)
except shapely.errors.GEOSException:
continue
_OVERLAY_FAILS.append(str(what))
return None
def repair_polygons(gdf):
"""buffer(0) on the invalid polygons (make_valid if that empties one)."""
geoms = gdf.geometry.values.copy()
bad = np.flatnonzero(~shapely.is_valid(geoms) & ~shapely.is_missing(geoms))
info = {"invalid": len(bad), "repaired": 0, "failed": [], "indices": list(gdf.index[bad][:20])}
for k in bad:
new = shapely.buffer(geoms[k], 0)
if shapely.is_empty(new) or not shapely.is_valid(new):
new = shapely.make_valid(geoms[k])
polys = [p for p in shapely.get_parts(new) if shapely.get_type_id(p) == 3]
new = shapely.multipolygons(polys) if polys else None
if new is None or shapely.is_empty(new):
info["failed"].append(gdf.index[k])
continue
if shapely.get_type_id(new) == 6 and shapely.get_num_geometries(new) == 1:
new = shapely.get_geometry(new, 0)
geoms[k] = new
info["repaired"] += 1
res = gdf.copy()
res["geometry"] = gpd.GeoSeries(geoms, index=gdf.index, crs=gdf.crs)
return res, info
def _plain_index(x):
"""A feature index as a plain Python value, so the report shows 909 rather than np.int64(909)."""
return x.item() if hasattr(x, "item") else x
def _on_segment(a, b, pts, K, near_m):
"""Of the points (m, 2), those within ``near_m`` metres of the inside of segment a-b, ordered from a to b."""
if len(pts) == 0:
return []
A, B, Q = np.asarray(a) * K, np.asarray(b) * K, np.asarray(pts) * K
d = B - A
L2 = float(d @ d)
if L2 == 0:
return []
t = ((Q - A) @ d) / L2
dist = np.hypot(*(Q - (A + t[:, None] * d)).T)
keep = (t > 0) & (t < 1) & (dist <= near_m)
return [tuple(pts[k]) for k in np.flatnonzero(keep)[np.argsort(t[keep], kind="stable")]]
def _cut_loops(c, max_m, K, near_m=0.0):
"""
Cut the loops out of one line's vertices (n, 2): wherever the line passes through the same point twice,
the vertices between the two visits are dropped, if all of them lie within ``max_m`` metres of that point
(K: metres per layer unit in x and y). Dropped vertices lying along one of the two segments now meeting at
the point (within ``near_m`` metres of it) are put back on it, in order, so that a fault that ran out along
a contact and back keeps the contact's nodes once. Repeated neighbouring vertices are merged. A closed line
keeps its closing vertex. Returns (vertices, loops cut, a larger loop was left).
"""
closed = len(c) > 3 and (c[0] == c[-1]).all()
body = [tuple(p) for p in (c[:-1] if closed else c)]
cut = 0
big = False
for _ in range(4 * len(body) + 4): # every step shortens the line or fixes a loop; bounded anyway
seen, done = {}, True
for pos, xy in enumerate(body):
i = seen.get(xy)
if i is not None:
loop = np.asarray(body[i:pos + 1])
extent = np.hypot(*((loop - loop[0]) * K).T).max()
if pos - i == 1 or extent <= max_m:
dropped = body[i + 1:pos]
body = body[:i + 1] + body[pos + 1:]
if dropped and near_m > 0:
present = set(body)
dropped = list(dict.fromkeys(p for p in dropped if p not in present))
after = _on_segment(xy, body[i + 1], np.asarray(dropped), K, near_m) if i + 1 < len(body) else []
rest = [p for p in dropped if p not in set(after)]
before = _on_segment(body[i - 1], xy, np.asarray(rest), K, near_m) if i > 0 and rest else []
body = body[:i] + before + [xy] + after + body[i + 1:]
cut += pos - i > 1
done = False
break
big = True
seen[xy] = pos
if done:
break
out = np.asarray(body + ([body[0]] if closed else []))
return out, cut, big
def remove_line_loops(gdf, max_m, K, near_m=0.0):
"""
Cut short loops out of lines. Snapping a vertex onto a node, and adding that node to a nearby segment of the
same line, can make a line pass through the node twice with a loop of a few metres in between (often where the
line was digitised with a hairpin or spike). Loops whose vertices all lie within ``max_m`` metres of the
repeated point are cut out; nothing is moved. Returns (gdf, info): info has "loops" (number cut), "indices"
(features changed) and "left" (features that still cross or touch themselves).
"""
geoms = gdf.geometry.values.copy()
info = {"loops": 0, "indices": [], "left": []}
ok = ~shapely.is_missing(geoms) & ~shapely.is_empty(geoms)
for k in np.flatnonzero(ok & ~shapely.is_simple(np.where(ok, geoms, None))):
parts = shapely.get_parts(geoms[k])
new_parts, n_cut = [], 0
for part in parts:
c = shapely.get_coordinates(part)
c2, cut, _ = _cut_loops(c, max_m, K, near_m)
n_cut += cut
new_parts.append(shapely.linestrings(c2) if len(c2) >= 2 and cut else part)
if n_cut:
geoms[k] = new_parts[0] if shapely.get_type_id(geoms[k]) == 1 else shapely.multilinestrings(new_parts)
info["loops"] += n_cut
info["indices"].append(_plain_index(gdf.index[k]))
if not shapely.is_simple(geoms[k]):
info["left"].append(_plain_index(gdf.index[k]))
res = gdf.copy()
res["geometry"] = gpd.GeoSeries(geoms, index=gdf.index, crs=gdf.crs)
return res, info
def remove_duplicates(gdf):
"""Remove features whose geometry is identical (any start point / direction) to an earlier one."""
geoms = gdf.geometry.values
ok = ~shapely.is_missing(geoms) & ~shapely.is_empty(geoms)
wkb = np.full(len(geoms), None, dtype=object)
wkb[ok] = shapely.to_wkb(shapely.normalize(geoms[ok]))
dup = pd.Series(wkb).duplicated().values & ok
info = {"removed": int(dup.sum()), "indices": list(gdf.index[dup][:20])}
return gdf.loc[~dup].copy(), info
def cut_contained_polygons(gdf, pad, min_inside=0.9, only=None):
"""
A polygon that lies (for at least ``min_inside`` of its area) inside a larger polygon, without
a hole in it, gets cut out of the larger polygon as a hole. ``pad`` is a bounding-box margin
in CRS units.
"""
geoms = gdf.geometry.values.copy()
n = len(geoms)
grid = _grid(gdf)
info = {"holes_cut": 0, "containers": 0, "examples": []}
if n < 2:
return gdf, info
ok = ~shapely.is_missing(geoms) & ~shapely.is_empty(geoms)
area = np.where(ok, shapely.area(geoms), 0.0)
tree = shapely.STRtree(geoms)
if only is None:
qi, ti = tree.query(geoms, predicate="contains")
qo, to = tree.query(geoms, predicate="overlaps")
else: # only pairs that involve a polygon that changed
only = np.asarray(only, dtype=np.int64)
if len(only) == 0:
return gdf, info
a_, b_ = tree.query(geoms[only], predicate="contains") # a changed polygon contains b
c_, d_ = tree.query(geoms[only], predicate="within") # a changed polygon lies inside d
qi = np.concatenate([only[a_], d_])
ti = np.concatenate([b_, only[c_]])
e_, f_ = tree.query(geoms[only], predicate="overlaps")
qo = np.concatenate([only[e_], f_]) # both orientations: the
to = np.concatenate([f_, only[e_]]) # bigger one is picked below
m = (qi != ti) & (area[qi] > area[ti])
ci, cj = qi[m], ti[m]
m = area[qo] > area[to]
qo, to = qo[m], to[m]
if len(qo):
b = shapely.bounds(geoms)
# at least ``min_inside`` of the smaller polygon's area must fall in the overlap of the two bounding boxes
# (a multi-part polygon with a small remote part has a bounding box far larger than its area suggests)
ix = np.clip(np.minimum(b[qo, 2], b[to, 2]) - np.maximum(b[qo, 0], b[to, 0]), 0, None)
iy = np.clip(np.minimum(b[qo, 3], b[to, 3]) - np.maximum(b[qo, 1], b[to, 1]), 0, None)
near = ix * iy >= min_inside * area[to]
qo, to = qo[near], to[near]
if len(qo):
try:
inter = shapely.area(shapely.intersection(geoms[qo], geoms[to]))
except shapely.errors.GEOSException: # one pair is too degenerate: do them one by one
inter = np.zeros(len(qo))
for k_, (a_, b_) in enumerate(zip(qo, to)):
r_ = _robust(shapely.intersection, geoms[a_], geoms[b_], grid, f"{gdf.index[a_]}/{gdf.index[b_]}")
inter[k_] = shapely.area(r_) if r_ is not None else 0.0
m = inter >= min_inside * area[to]
qo, to = qo[m], to[m]
ci = np.concatenate([ci, qo])
cj = np.concatenate([cj, to])
if len(ci) == 0:
return gdf, info
order = np.argsort(ci, kind="stable")
ci, cj = ci[order], cj[order]
for i in np.unique(ci):
js = np.unique(cj[ci == i])
un = _robust_union_all(geoms[js], grid, f"{gdf.index[i]}")
new = _robust(shapely.difference, geoms[i], un, grid, f"{gdf.index[i]}") if un is not None else None
if new is None:
continue
polys = [p for p in shapely.get_parts(new) if shapely.get_type_id(p) == 3]
if not polys:
continue
geoms[i] = polys[0] if len(polys) == 1 and shapely.get_type_id(geoms[i]) == 3 else shapely.multipolygons(polys)
info["containers"] += 1
info["holes_cut"] += len(js)
if len(info["examples"]) < 10:
info["examples"].append((gdf.index[i], [gdf.index[j] for j in js][:5]))
res = gdf.copy()
res["geometry"] = gpd.GeoSeries(geoms, index=gdf.index, crs=gdf.crs)
return res, info
def _gap_polygons(u, grid=None):
"""
The gaps in a union of polygons: every hole in it, minus any polygon that lies inside that hole
(the outline of a hole also encloses such islands).
"""
parts = shapely.get_parts(u)
grid = grid if grid is not None else _grid(bounds=shapely.bounds(u))
tree = shapely.STRtree(parts)
holes = []
for pi, part in enumerate(parts):
for ring in shapely.get_rings(part)[1:]:
h = shapely.polygons(ring)
inside = [k for k in tree.query(h, predicate="contains") if k != pi]
if inside:
un = _robust_union_all(parts[inside], grid, "gap")
h2 = _robust(shapely.difference, h, un, grid, "gap") if un is not None else None
h = h2 if h2 is not None else h
if not shapely.is_empty(h):
holes.append(h)
return holes
def remove_overlaps(gdf, keep="smaller"):
"""
Every remaining overlap between two polygons (partial overlaps, or one inside the other) is cut out of one of
them, so that no two polygons overlap any more. ``keep="smaller"``: the smaller polygon keeps the overlap and the
bigger one loses it (the same convention as a hole cut for a polygon inside another); ``keep="larger"``: the other
way round. A polygon that is entirely overlapped disappears. Returns (gdf, info).
"""
geoms = gdf.geometry.values.copy()
grid = _grid(gdf)
info = {"pairs": 0, "area": 0.0, "removed": [], "examples": []}
n = len(geoms)
if n < 2:
return gdf, info
ok = ~shapely.is_missing(geoms) & ~shapely.is_empty(geoms)
area0 = np.where(ok, shapely.area(geoms), 0.0)
tree = shapely.STRtree(geoms)
a1, b1 = tree.query(geoms, predicate="overlaps")
a2, b2 = tree.query(geoms, predicate="contains")
qi = np.concatenate([a1, a2])
ti = np.concatenate([b1, b2])
m = qi != ti
qi, ti = qi[m], ti[m]
if len(qi) == 0:
return gdf, info
lo_, hi_ = np.minimum(qi, ti).astype(np.int64), np.maximum(qi, ti).astype(np.int64)
_, first = np.unique(lo_ * n + hi_, return_index=True) # each pair once
lo_, hi_ = lo_[first], hi_[first]
for i, j in zip(lo_, hi_):
bigger, smaller = (i, j) if (area0[i], -i) > (area0[j], -j) else (j, i)
loser, winner = (bigger, smaller) if keep == "smaller" else (smaller, bigger)
if shapely.is_empty(geoms[loser]) or shapely.is_empty(geoms[winner]):
continue
what = f"{gdf.index[loser]}/{gdf.index[winner]}"
ov_ = _robust(shapely.intersection, geoms[loser], geoms[winner], grid, what)
if ov_ is None:
continue
inter = shapely.area(ov_)
if inter <= 1e-9 * max(min(area0[i], area0[j]), 1e-30):
continue # only touching, or numerical noise
new = _robust(shapely.difference, geoms[loser], geoms[winner], grid, what)
if new is None:
continue
parts = [x for x in shapely.get_parts(new) if shapely.get_type_id(x) == 3 and x.area > 0]
if not parts:
geoms[loser] = shapely.Polygon()
info["removed"].append(gdf.index[loser])
else:
geoms[loser] = parts[0] if (len(parts) == 1 and shapely.get_type_id(geoms[loser]) == 3) else shapely.multipolygons(parts)
info["pairs"] += 1
info["area"] += float(inter)
if len(info["examples"]) < 10:
info["examples"].append((gdf.index[loser], gdf.index[winner]))
res = gdf.copy()
res["geometry"] = gpd.GeoSeries(geoms, index=gdf.index, crs=gdf.crs)
if info["removed"]:
res = res.loc[~res.index.isin(info["removed"])].copy()
return res, info
def fill_thin_gaps(gdf, width):
"""
Gaps between polygons that are narrower than ``width`` (CRS units) everywhere (all of them if ``width`` is
None) are added to the
neighbouring polygon with which they share the longest boundary. Only holes completely enclosed
by polygons are gaps; open slivers along the edge of the map and wide holes are left alone.
Returns (gdf, info).
"""
geoms = gdf.geometry.values.copy()
grid = _grid(gdf)
info = {"gaps": 0, "area": 0.0, "examples": []}
ok = ~shapely.is_missing(geoms) & ~shapely.is_empty(geoms)
if ok.sum() < 2:
return gdf, info
valid = np.where(shapely.is_valid(geoms), geoms, shapely.make_valid(geoms))
u = _robust_union_all(valid[ok], grid, "union of all polygons")
if u is None:
return gdf, info
holes = _gap_polygons(u, grid)
if not holes:
return gdf, info
holes = np.array(holes, dtype=object)
if width is not None: # None: every gap completely surrounded by polygons
thin = shapely.is_empty(shapely.buffer(holes, -0.5 * width))
holes = holes[thin]
if len(holes) == 0:
return gdf, info
tree = shapely.STRtree(geoms)
for h in holes:
cand = tree.query(h, predicate="intersects")
cand = [k for k in cand if ok[k]]
if not cand:
continue
hb = shapely.boundary(h)
shared = []
for k in cand:
r_ = _robust(shapely.intersection, hb, geoms[k], grid, f"gap/{gdf.index[k]}")
shared.append(shapely.length(r_) if r_ is not None else 0.0)
best = cand[int(np.argmax(shared))]
if max(shared) <= 0:
continue
new = _robust(shapely.union, geoms[best], h, grid, f"gap/{gdf.index[best]}")
if new is None:
continue
try: # the union copies a corner from two neighbours with float noise between: one vertex is enough
new = shapely.remove_repeated_points(new, tolerance=10 * grid)
except (AttributeError, shapely.errors.GEOSException):
pass
parts = [x for x in shapely.get_parts(new) if shapely.get_type_id(x) == 3]
if not parts:
continue
geoms[best] = parts[0] if (len(parts) == 1 and shapely.get_type_id(geoms[best]) == 3) else shapely.multipolygons(parts)
info["gaps"] += 1
info["area"] += float(shapely.area(h))
if len(info["examples"]) < 10:
info["examples"].append(gdf.index[best])
res = gdf.copy()
res["geometry"] = gpd.GeoSeries(geoms, index=gdf.index, crs=gdf.crs)
return res, info
def resolve_thin_overlaps(gdf, width, only=None):
"""
Where two polygons overlap in a strip narrower than ``width`` (CRS units) everywhere, the overlap is
taken out of the smaller polygon. Wider overlaps are left alone. Returns (gdf, info).
"""
geoms = gdf.geometry.values.copy()
grid = _grid(gdf)
info = {"pairs": 0, "area": 0.0, "examples": []}
n = len(geoms)
if n < 2:
return gdf, info
ok = ~shapely.is_missing(geoms) & ~shapely.is_empty(geoms)
area = np.where(ok, shapely.area(geoms), 0.0)
tree = shapely.STRtree(geoms)
if only is None:
qi, ti = tree.query(geoms, predicate="overlaps")
else:
only = np.asarray(only, dtype=np.int64)
if len(only) == 0:
return gdf, info
a_, b_ = tree.query(geoms[only], predicate="overlaps")
qi = np.concatenate([only[a_], b_]) # both orientations; the next lines
ti = np.concatenate([b_, only[a_]]) # keep the one with qi bigger
m = (area[qi] > area[ti]) | ((area[qi] == area[ti]) & (qi < ti)) # qi is the bigger one
qi, ti = qi[m], ti[m]
if len(qi) == 0:
return gdf, info
for i, j in zip(qi, ti):
big, small = geoms[i], geoms[j]
what = f"{gdf.index[j]}/{gdf.index[i]}"
inter = _robust(shapely.intersection, big, small, grid, what)
if inter is None:
continue
polys = [x for x in shapely.get_parts(inter) if shapely.get_type_id(x) == 3]
if not polys:
continue
ov = shapely.multipolygons(polys)
if not shapely.is_empty(shapely.buffer(ov, -0.5 * width)):
continue # wide: a real overlap, leave it
new = _robust(shapely.difference, small, big, grid, what)
if new is None:
continue
parts = [x for x in shapely.get_parts(new) if shapely.get_type_id(x) == 3]
if not parts:
continue
geoms[j] = parts[0] if (len(parts) == 1 and shapely.get_type_id(small) == 3) else shapely.multipolygons(parts)
info["pairs"] += 1
info["area"] += float(shapely.area(ov))
if len(info["examples"]) < 10:
info["examples"].append((gdf.index[j], gdf.index[i]))
res = gdf.copy()
res["geometry"] = gpd.GeoSeries(geoms, index=gdf.index, crs=gdf.crs)
return res, info
# =============================================================================
# STEP 4: CONFORM
# =============================================================================
def _candidates(coords, pid, rank, tol, dirty=None, orig=None):
"""
Everything one pass needs: unique vertices (sites) and unique segments, and every pair of a site
with a segment of another path that lies within ``tol`` of it, without being one of its nodes.
With ``dirty`` (an array of points where something changed in the previous pass) only the
neighbourhood of those points is examined: sites within 2 tol of one, and the segments that pass
within 3 tol of one. Everything else was examined before and has not changed.
"""
from scipy.spatial import cKDTree
P = len(rank)
n = len(coords)
has_next = np.r_[pid[:-1] == pid[1:], False]
vpos = np.flatnonzero(has_next)
queried = None
if dirty is None:
rows = None
sub = coords
sub_pid = pid
else:
Dt = cKDTree(dirty)
d_row, _ = Dt.query(coords, k=1, distance_upper_bound=3.0 * tol, workers=-1)
# A segment with a changed end point has moved as a whole: a long one can now pass close to
# vertices far from its ends. Treat points along such segments as changed too.
seg_len = np.hypot(*(coords[vpos + 1] - coords[vpos]).T)
changed_long = ((d_row[vpos] == 0) | (d_row[vpos + 1] == 0)) & (seg_len > 2.0 * tol)
if changed_long.any():
va = vpos[changed_long]
ns = np.ceil(seg_len[changed_long] / tol).astype(np.int64)
if ns.sum() > 0.25 * n:
return _candidates(coords, pid, rank, tol, None, orig) # too much changed: look at everything
rep_s = np.repeat(np.arange(len(va)), ns)
frac = (np.arange(len(rep_s)) - np.repeat(np.cumsum(ns) - ns, ns) + 0.5) / ns[rep_s]
extra = coords[va][rep_s] + frac[:, None] * (coords[va + 1][rep_s] - coords[va][rep_s])
dirty = np.vstack([dirty, extra])
Dt = cKDTree(dirty)
d_row, _ = Dt.query(coords, k=1, distance_upper_bound=3.0 * tol, workers=-1)
near3 = np.isfinite(d_row)
mid = 0.5 * (coords[vpos] + coords[vpos + 1])
half = 0.5 * np.hypot(*(coords[vpos + 1] - coords[vpos]).T)
d_mid, _ = Dt.query(mid, k=1, workers=-1)
vpos = vpos[d_mid <= half + 3.0 * tol]
sel = near3.copy()
sel[vpos] = True
sel[vpos + 1] = True
rows = np.flatnonzero(sel)
sub = coords[rows]
sub_pid = pid[rows]
near2_rows = d_row[rows] <= 2.0 * tol
uv, inv = np.unique(sub, axis=0, return_inverse=True)
inv = inv.ravel()
prank = rank[sub_pid]
site_rank = np.full(len(uv), BIG, dtype=np.int64)
np.minimum.at(site_rank, inv, prank)
if dirty is not None:
queried = np.zeros(len(uv), dtype=bool)
queried[inv[near2_rows]] = True
inv_full = np.full(n, -1, dtype=np.int64)
inv_full[rows] = inv
else:
inv_full = inv
site_lo = site_hi = None
if orig is not None: # the box around where the rows of each site started from
osub = orig if rows is None else orig[rows]
site_lo = np.full((len(uv), 2), np.inf)
site_hi = np.full((len(uv), 2), -np.inf)
for d_ in (0, 1):
np.minimum.at(site_lo[:, d_], inv, osub[:, d_])
np.maximum.at(site_hi[:, d_], inv, osub[:, d_])
A, B = coords[vpos], coords[vpos + 1]
flip = (A[:, 0] > B[:, 0]) | ((A[:, 0] == B[:, 0]) & (A[:, 1] > B[:, 1]))
lo = np.where(flip[:, None], B, A)
hi = np.where(flip[:, None], A, B)
uniq, sinv = np.unique(np.concatenate([lo, hi], axis=1), axis=0, return_inverse=True)
sinv = sinv.ravel()
ulo, uhi = uniq[:, :2], uniq[:, 2:]
U = len(uniq)
seg_path = pid[vpos]
seg_rank = np.full(U, BIG, dtype=np.int64)
np.minimum.at(seg_rank, sinv, rank[seg_path])
path_of_rank = np.empty(P, dtype=np.int64)
path_of_rank[rank] = np.arange(P)
seg_owner = path_of_rank[np.minimum(seg_rank, P - 1)]
seg_lo_site = np.empty(U, dtype=np.int64)
seg_hi_site = np.empty(U, dtype=np.int64)
s0, s1 = inv_full[vpos], inv_full[vpos + 1]
seg_lo_site[sinv] = np.where(flip, s1, s0)
seg_hi_site[sinv] = np.where(flip, s0, s1)
segs = shapely.linestrings(np.stack([ulo, uhi], axis=1))
tree = shapely.STRtree(segs)
if queried is None:
si, gi = tree.query(shapely.points(uv), predicate="dwithin", distance=tol)
else:
qsites = np.flatnonzero(queried)
si, gi = tree.query(shapely.points(uv[qsites]), predicate="dwithin", distance=tol)
si = qsites[si]
Q = uv[si]
a, b = ulo[gi], uhi[gi]
keep = ~(np.all(Q == a, axis=1) | np.all(Q == b, axis=1))
si, gi, Q, a, b = si[keep], gi[keep], Q[keep], a[keep], b[keep]
# drop pairs where the site and the segment belong to a common path
sp = np.unique(inv.astype(np.int64) * P + sub_pid)
gp = np.unique(sinv.astype(np.int64) * P + seg_path)
lo_i = np.searchsorted(sp, si.astype(np.int64) * P)
hi_i = np.searchsorted(sp, (si.astype(np.int64) + 1) * P)
cnt = hi_i - lo_i
rep = np.repeat(np.arange(len(si)), cnt)
off = np.arange(len(rep)) - np.repeat(np.cumsum(cnt) - cnt, cnt)
pth = sp[lo_i[rep] + off] - si[rep].astype(np.int64) * P
key = gi[rep].astype(np.int64) * P + pth
pos = np.minimum(np.searchsorted(gp, key), len(gp) - 1)
shared = np.bincount(rep, weights=(gp[pos] == key), minlength=len(si)) > 0
si, gi, Q, a, b = si[~shared], gi[~shared], Q[~shared], a[~shared], b[~shared]
d = b - a
L2 = (d * d).sum(axis=1)
t = ((Q - a) * d).sum(axis=1) / L2
q = a + np.clip(t, 0, 1)[:, None] * d
dist = np.hypot(Q[:, 0] - q[:, 0], Q[:, 1] - q[:, 1])
ok = dist <= tol
# a vertex whose nearest point is a node belonging to its own path is just a short segment
clamped = np.flatnonzero(ok & ((t <= 0) | (t >= 1)))
if len(clamped):
e_site = np.where(t[clamped] <= 0, seg_lo_site[gi[clamped]], seg_hi_site[gi[clamped]])
s_c = si[clamped].astype(np.int64)
lo_i = np.searchsorted(sp, s_c * P)
hi_i = np.searchsorted(sp, (s_c + 1) * P)
cnt = hi_i - lo_i
rep = np.repeat(np.arange(len(clamped)), cnt)
off = np.arange(len(rep)) - np.repeat(np.cumsum(cnt) - cnt, cnt)
pth = sp[lo_i[rep] + off] - s_c[rep] * P
key = e_site[rep].astype(np.int64) * P + pth
pos = np.minimum(np.searchsorted(sp, key), len(sp) - 1)
own = np.bincount(rep, weights=(sp[pos] == key), minlength=len(clamped)) > 0
ok[clamped[own]] = False
return {"uv": uv, "inv": inv, "rows": rows, "vpos": vpos, "flip": flip, "sinv": sinv, "U": U,
"site_lo": site_lo, "site_hi": site_hi,
"site_rank": site_rank, "seg_rank": seg_rank, "seg_owner": seg_owner, "P": P,
"si": si[ok], "gi": gi[ok], "t": t[ok], "q": q[ok], "dist": dist[ok],
"a": a[ok], "b": b[ok]}
def _count_defects(coords, pid, rank, tol):
"""(unmatched nodes, near-duplicate nodes): what a conform pass would act on."""
if len(coords) == 0:
return 0, 0
c = _candidates(coords, pid, rank, tol)
P = c["P"]
interior = (c["t"] > 0) & (c["t"] < 1)
unmatched = len(np.unique(c["si"][interior].astype(np.int64) * P + c["seg_owner"][c["gi"][interior]]))
near = ~interior
pa = np.where((c["t"][near] <= 0)[:, None], c["a"][near], c["b"][near])
pair = np.unique(np.concatenate([c["si"][near, None].astype(float), pa], axis=1), axis=0)
return int(unmatched), int(len(pair))
def _insert_nodes(coords, pid, c, ins_seg, ins_t, ins_xy, orig=None):
"""Put the new nodes into the paths (once per unique segment, read in each path's direction)."""
n = len(coords)
U = c["U"]
order = np.lexsort((ins_t, ins_seg))
ins_seg, ins_xy = ins_seg[order], ins_xy[order]
m_u = np.bincount(ins_seg, minlength=U)
off = np.cumsum(m_u) - m_u
cnt = np.zeros(n, dtype=np.int64)
cnt[c["vpos"]] = m_u[c["sinv"]]
ub = np.zeros(n, dtype=np.int64)
ub[c["vpos"]] = c["sinv"]
fl = np.zeros(n, dtype=bool)
fl[c["vpos"]] = c["flip"]
place = np.cumsum(1 + cnt) - (1 + cnt)
out = np.empty((int((1 + cnt).sum()), 2))
out[place] = coords
v = np.repeat(np.arange(n), cnt)
m = np.arange(len(v)) - np.repeat(np.cumsum(cnt) - cnt, cnt)
take = off[ub[v]] + np.where(fl[v], cnt[v] - 1 - m, m)
out[place[v] + 1 + m] = ins_xy[take]
orig_out = None
if orig is not None: # a new node starts where it is
orig_out = np.empty_like(out)
orig_out[place] = orig
orig_out[place[v] + 1 + m] = ins_xy[take]
return out, np.repeat(pid, 1 + cnt), orig_out
def _cleanup(coords, pid, path_is_poly, orig=None):
"""Drop repeated vertices and spikes (A-B-A); drop paths that collapsed."""
for _ in range(8):
if len(coords) < 2:
break
same_prev = np.r_[False, (pid[1:] == pid[:-1]) & np.all(coords[1:] == coords[:-1], axis=1)]
spike = np.zeros(len(coords), dtype=bool)
if len(coords) > 2:
spike[1:-1] = (pid[1:-1] == pid[:-2]) & (pid[1:-1] == pid[2:]) & np.all(coords[:-2] == coords[2:], axis=1)
drop = same_prev | spike
if not drop.any():
break
coords, pid = coords[~drop], pid[~drop]
if orig is not None:
orig = orig[~drop]
need = np.where(path_is_poly, 4, 2)
rows = np.bincount(pid, minlength=len(path_is_poly))
dead = rows < need
if path_is_poly.any() and len(coords) > 2:
first = np.r_[True, pid[1:] != pid[:-1]]
f0 = np.maximum.accumulate(np.where(first, np.arange(len(pid)), 0))
xs, ys = coords[:, 0] - coords[f0, 0], coords[:, 1] - coords[f0, 1]
cross = np.zeros(len(pid))
same = pid[:-1] == pid[1:]
cross[:-1] = np.where(same, xs[:-1] * ys[1:] - xs[1:] * ys[:-1], 0.0)
ring_area = np.abs(np.bincount(pid, weights=cross, minlength=len(path_is_poly))) / 2
dead |= path_is_poly & (ring_area < 1e-3) # rings that collapsed to a line
if dead.any():
k = ~dead[pid]
coords, pid = coords[k], pid[k]
if orig is not None:
orig = orig[k]
return coords, pid, orig
def _conform_pass(coords, pid, rank, path_is_poly, tol, dirty=None, orig=None, cap=None):
"""One conform pass. With ``cap`` (and ``orig``, where every row started) no vertex ends up more than
``cap`` from where it started."""
c = _candidates(coords, pid, rank, tol, dirty, orig if cap is not None else None)
lo_, hi_ = c["site_lo"], c["site_hi"]
def allowed(sites, targets):
if cap is None or lo_ is None:
return np.ones(len(sites), dtype=bool)
far = np.maximum(np.abs(targets - lo_[sites]), np.abs(targets - hi_[sites]))
return np.hypot(far[:, 0], far[:, 1]) <= cap
P = c["P"]
si, gi, t, q, dist, a, b = c["si"], c["gi"], c["t"], c["q"], c["dist"], c["a"], c["b"]
uv = c["uv"]
slave = c["site_rank"][si] > c["seg_rank"][gi]
# slave vertices: move onto the nearest node of a higher-ranked line within the tolerance
# (a node is on that line anyway, and this merges the near-coincident nodes of a junction);
# if there is none, onto the nearest point of the nearest segment, which becomes a node
idx = np.flatnonzero(slave)
new_uv = uv.copy()
ins_seg = np.zeros(0, dtype=np.int64); ins_t = np.zeros(0); ins_xy = np.zeros((0, 2))
n_move = 0
parts_seg, parts_t, parts_xy = [], [], []
if len(idx):
Qs = uv[si[idx]]
nd_a = np.hypot(*(Qs - a[idx]).T)
nd_b = np.hypot(*(Qs - b[idx]).T)
use_a = nd_a <= nd_b
nd = np.where(use_a, nd_a, nd_b)
nxy = np.where(use_a[:, None], a[idx], b[idx])
has = (nd <= tol) & allowed(si[idx], nxy)
done = np.zeros(len(uv), dtype=bool)
if has.any():
h = np.flatnonzero(has)
order = h[np.lexsort((nd[h], si[idx][h]))]
sites = si[idx][order]
first = np.r_[True, sites[1:] != sites[:-1]]
site = sites[first]
new_uv[site] = nxy[order][first]
done[site] = True
rest = idx[~done[si[idx]]]
if len(rest):
order = rest[np.lexsort((gi[rest], dist[rest], si[rest]))]
first = np.r_[True, si[order][1:] != si[order][:-1]]
sel = order[first]
sel = sel[allowed(si[sel], q[sel])]
site = si[sel]
new_uv[site] = q[sel]
parts_seg.append(gi[sel]); parts_t.append(t[sel]); parts_xy.append(q[sel])
moved_sites = np.flatnonzero(np.any(new_uv != uv, axis=1))
n_move = int(len(moved_sites))
# higher-ranked vertices: become nodes of the lower-ranked line passing near them
m_idx = np.flatnonzero(~slave & (t > 0) & (t < 1))
if len(m_idx):
key = si[m_idx].astype(np.int64) * P + c["seg_owner"][gi[m_idx]]
order = m_idx[np.lexsort((dist[m_idx], key))]
ks = key[np.lexsort((dist[m_idx], key))]
first = np.r_[True, ks[1:] != ks[:-1]]
sel = order[first]
parts_seg.append(gi[sel]); parts_t.append(t[sel]); parts_xy.append(uv[si[sel]])
if parts_seg:
ins_seg = np.concatenate(parts_seg); ins_t = np.concatenate(parts_t); ins_xy = np.concatenate(parts_xy)
# the same node can arrive from several vertices
k = np.round(ins_xy / 1e-6).astype(np.int64)
_, u = np.unique(np.column_stack([ins_seg, k]), axis=0, return_index=True)
ins_seg, ins_t, ins_xy = ins_seg[u], ins_t[u], ins_xy[u]
changed_sites = np.flatnonzero(np.any(new_uv != uv, axis=1))
if c["rows"] is None:
coords = new_uv[c["inv"]]
else:
coords = coords.copy()
coords[c["rows"]] = new_uv[c["inv"]]
if len(ins_seg):
coords, pid, orig_new = _insert_nodes(coords, pid, c, ins_seg, ins_t, ins_xy, orig if cap is not None else None)
if cap is not None:
orig = orig_new
coords, pid, orig = _cleanup(coords, pid, path_is_poly, orig if cap is not None else None)
# where something changed: the next pass only needs to look around these points
dirty_out = np.concatenate([new_uv[changed_sites], uv[changed_sites], ins_xy]) if (len(changed_sites) or len(ins_seg)) \
else np.zeros((0, 2))
return coords, pid, n_move, int(len(ins_seg)), dirty_out, orig
def _conform(coords, pid, rank, path_is_poly, tol, max_pass=40, log=None, dirty=None, cap=None):
"""
Conform passes until nothing changes. The first pass looks at everything (or, given ``dirty``,
around those points); later passes only look around what the previous pass changed. When the local
passes find nothing a last full pass checks that nothing was missed.
"""
stats = {"passes": 0, "vertices_moved": 0, "nodes_inserted": 0}
orig = coords.copy() if cap is not None else None
last_ni, same_ni = -1, 0
for _ in range(max_pass):
t0 = time.time()
local = dirty is not None
coords, pid, nm, ni, d_out, orig = _conform_pass(coords, pid, rank, path_is_poly, tol, dirty, orig, cap)
stats["passes"] += 1
stats["vertices_moved"] += nm
stats["nodes_inserted"] += ni
if log:
log(f" pass {stats['passes']}{' (local)' if local else ' (full)'}: {nm} vertices moved, "
f"{ni} nodes inserted ({time.time() - t0:.1f} s)")
if nm == 0 and ni == 0:
if not local:
break # a full pass found nothing: finished
dirty = None # verify with one full pass
continue
# the same nodes inserted again and again, with nothing moving, means they are not sticking: stop
same_ni = same_ni + 1 if (nm == 0 and ni == last_ni) else 0
last_ni = ni
if same_ni >= 12:
if log:
log(f" stopped: the same {ni} nodes were inserted in 12 passes in a row without anything moving")
stats["stalled"] = ni
break
dirty = d_out
stats["max_move"] = float(np.hypot(*(coords - orig).T).max()) if (orig is not None and len(coords)) else 0.0
return coords, pid, stats
# =============================================================================
# PUTTING LAYERS BACK TOGETHER
# =============================================================================
def _assemble(gdf, info, coords, pid, base, alive):
"""New geometries of a layer from its (possibly changed) paths; features that vanished are dropped."""
n_paths = len(info["paths"])
n_rings = info["n_rings"]
alive = alive[base:base + n_paths]
sel = (pid >= base) & (pid < base + n_paths)
cc, pp = coords[sel], pid[sel] - base
ring_geoms = np.full(n_rings, None, dtype=object)
ridx = np.flatnonzero(alive[:n_rings])
if len(ridx):
m = pp < n_rings
_, rel = np.unique(pp[m], return_inverse=True)
ring_geoms[ridx] = shapely.linearrings(cc[m], indices=rel)
parts = info["parts"]
new_parts = np.full(len(parts), None, dtype=object)
if n_rings:
rp = info["ring_poly"]
first = np.r_[True, rp[1:] != rp[:-1]]
shell_pos = np.flatnonzero(first)
ends = np.r_[shell_pos[1:], n_rings]
polys = np.full(len(shell_pos), None, dtype=object)
shell_alive = alive[shell_pos]
simple = shell_alive & (ends - shell_pos == 1)
if simple.any():
polys[simple] = shapely.polygons(ring_geoms[shell_pos[simple]])
for q in np.flatnonzero(shell_alive & ~simple):
holes = [ring_geoms[k] for k in range(shell_pos[q] + 1, ends[q]) if alive[k]]
polys[q] = shapely.Polygon(ring_geoms[shell_pos[q]], holes)
# an empty polygon has no rings: ring_poly says which polygon each group of rings belongs to
all_polys = np.full(len(info["poly_pos"]), None, dtype=object)
all_polys[rp[shell_pos]] = polys
new_parts[info["poly_pos"]] = all_polys
if len(info["lines"]):
line_alive = alive[n_rings:]
lines = np.full(len(info["lines"]), None, dtype=object)
lidx = np.flatnonzero(line_alive)
if len(lidx):
m = pp >= n_rings
_, rel = np.unique(pp[m], return_inverse=True)
lines[lidx] = shapely.linestrings(cc[m], indices=rel)
new_parts[info["line_pos"]] = lines
geoms, feat = info["geoms"], info["feat"]
out = geoms.copy()
otype = shapely.get_type_id(geoms)
alive_part = ~shapely.is_missing(new_parts)
cnt = np.bincount(feat[alive_part], minlength=len(geoms))
had = np.bincount(feat, minlength=len(geoms)) > 0
dropped = had & (cnt == 0)
ap = np.flatnonzero(alive_part)
single = np.isin(otype, (1, 2, 3)) & (cnt >= 1)
if single.any():
uf, fi = np.unique(feat[ap], return_index=True)
pick = dict(zip(uf, ap[fi]))
for f in np.flatnonzero(single):
out[f] = new_parts[pick[f]]
for type_id, make in ((6, shapely.multipolygons), (5, shapely.multilinestrings)):
feats = np.flatnonzero((otype == type_id) & (cnt >= 1))
if len(feats):
keep = alive_part & np.isin(feat, feats)
uf, inv = np.unique(feat[keep], return_inverse=True)
out[uf] = make(new_parts[keep], indices=inv)
res = gdf.copy()
res["geometry"] = gpd.GeoSeries(out, index=gdf.index, crs=gdf.crs)
return res.loc[~dropped].copy(), list(gdf.index[dropped])
# =============================================================================
# MEASURING
# =============================================================================
def _stream(layers, infos):
paths = [i["paths"] for i in infos if len(i["paths"])]
if not paths:
return None
all_paths = np.concatenate(paths)
coords, pid = shapely.get_coordinates(all_paths, return_index=True)
is_poly = np.concatenate([np.r_[np.ones(i["n_rings"], bool), np.zeros(len(i["paths"]) - i["n_rings"], bool)] for i in infos])
size = np.concatenate([
np.r_[shapely.area(i["parts"][i["poly_pos"]])[i["ring_poly"]] if i["n_rings"] else np.zeros(0),
shapely.length(i["lines"]) if len(i["lines"]) else np.zeros(0)] for i in infos])
# rank: polygons before lines, larger before smaller (stable on ties)
order = np.lexsort((-size, ~is_poly))
rank = np.empty(len(order), dtype=np.int64)
rank[order] = np.arange(len(order))
return all_paths, coords, pid, is_poly, rank
def topology_report(layers, tol_m, geographic, kinds=None):
"""Counts of the defects Fix Map looks for (used before and after)."""
kinds = kinds or [_kind(g) for g in layers]
K = _metric_scale([g for g in layers], geographic)
r = {}
infos = [_layer_paths(g) for g in layers]
st = _stream(layers, infos)
if st is not None:
_, coords, pid, is_poly, rank = st
r["unmatched_nodes"], r["near_duplicate_nodes"] = _count_defects(coords * K, pid, rank, tol_m)
poly_layers = [g for g, k in zip(layers, kinds) if k == "polygon"]
for g, k in zip(layers, kinds):
geoms = g.geometry.values
ok = ~shapely.is_missing(geoms) & ~shapely.is_empty(geoms)
if k == "polygon":
r["polygons"] = int(ok.sum())
r["invalid_polygons"] = int((~shapely.is_valid(geoms[ok])).sum())
r["duplicate_polygons"] = int(pd.Series(shapely.to_wkb(shapely.normalize(geoms[ok]))).duplicated().sum())
tree = shapely.STRtree(geoms[ok])
area = shapely.area(geoms[ok])
qi, ti = tree.query(geoms[ok], predicate="contains")
r["polygons_inside_polygons"] = int(((qi != ti) & (area[qi] > area[ti])).sum())
if ok.sum():
gv = geoms[ok]
gv = np.where(shapely.is_valid(gv), gv, shapely.make_valid(gv))
u = _robust_union_all(gv, _grid(g), "union for the report")
if u is None:
continue
area = shapely.area(gv)
scale = K[0] * K[1]
r["_union"] = u
r["overlap_area_m2"] = max(0.0, float((area.sum() - shapely.area(u)) * scale))
holes = _gap_polygons(u)
r["gaps"] = len(holes)
r["gaps_area_m2"] = float(sum(shapely.area(h) for h in holes) * scale)
elif k == "line":
r["faults"] = int(ok.sum())
r["duplicate_faults"] = int(pd.Series(shapely.to_wkb(shapely.normalize(geoms[ok]))).duplicated().sum())
return r
# =============================================================================
# PUBLIC INTERFACE
# =============================================================================