@@ -325,12 +325,20 @@ def clean_crowns(
325325 confidence = 0.2 ,
326326 area_threshold = 2 ,
327327 field = "Confidence_score" ,
328+ containment_threshold = 0.85 ,
328329 verbose = True ,
329330) -> gpd .GeoDataFrame :
330331 """
331332 Clean overlapping crowns by first identifying all candidate overlapping pairs via a spatial join,
332333 then clustering crowns into connected components (where an edge is added if two crowns have IoU
333- above a threshold), and finally keeping the best crown (by confidence or any given field) in each cluster.
334+ above a threshold, or one crown is largely contained within the other), and finally keeping the
335+ best crown (by confidence or any given field) in each cluster.
336+
337+ The containment check is important because IoU alone misses nested crowns: when a small crown
338+ sits inside a much larger one, IoU is low (the union is dominated by the large crown). The
339+ containment check merges these pairs so that the most confident crown wins. If the small crown
340+ is more confident, the large one is removed — and post_clean can then fill the gap with other
341+ crowns.
334342
335343 Args:
336344 crowns (gpd.GeoDataFrame): Crowns to be cleaned.
@@ -340,6 +348,9 @@ def clean_crowns(
340348 area_threshold (float, optional): Minimum area of crowns to be retained. Defaults to 2m2 (assuming UTM).
341349 field (str): Field to used to prioritise selection of crowns. Defaults to "Confidence_score" but this should
342350 be changed to "Area" if using a model that outputs area.
351+ containment_threshold (float, optional): Threshold for the containment ratio
352+ (intersection area / smaller crown area). When exceeded, the pair is merged and the most
353+ confident crown wins — just like IoU. Set to None to disable. Defaults to 0.85.
343354
344355 Returns:
345356 gpd.GeoDataFrame: Cleaned crowns.
@@ -376,7 +387,8 @@ def union(x, y):
376387 if rx != ry :
377388 parent [ry ] = rx
378389
379- # 4. For each candidate pair, compute IoU and, if it exceeds the threshold, merge the groups.
390+ # 4. For each candidate pair, check IoU and containment; merge if either exceeds its threshold.
391+ # The most confident crown in each merged cluster always wins (step 6).
380392 for idx , row in tqdm (
381393 join .iterrows (),
382394 total = len (join ),
@@ -389,10 +401,23 @@ def union(x, y):
389401 # To avoid duplicate work, skip if i and j are already in the same group.
390402 if find (i ) == find (j ):
391403 continue
392- # Compute the IoU for the pair.
393- iou_val = calc_iou (crowns .at [i , "geometry" ], crowns .at [j , "geometry" ])
404+
405+ geom_i = crowns .at [i , "geometry" ]
406+ geom_j = crowns .at [j , "geometry" ]
407+ intersection_area = geom_i .intersection (geom_j ).area
408+
409+ # IoU check
410+ union_area = geom_i .area + geom_j .area - intersection_area
411+ iou_val = intersection_area / union_area if union_area > 0 else 0
394412 if iou_val > iou_threshold :
395413 union (i , j )
414+ continue
415+
416+ # Containment check: is the smaller crown mostly inside the larger one?
417+ if containment_threshold is not None :
418+ min_area = min (geom_i .area , geom_j .area )
419+ if min_area > 0 and (intersection_area / min_area ) > containment_threshold :
420+ union (i , j )
396421
397422 # 5. Group crowns by their union-find root.
398423 groups : Dict [int , List ] = {}
@@ -481,78 +506,6 @@ def post_clean(unclean_df: gpd.GeoDataFrame,
481506 return current_clean
482507
483508
484- def remove_contained_crowns (
485- crowns : gpd .GeoDataFrame ,
486- containment_threshold : float = 0.85 ,
487- verbose : bool = True ,
488- ) -> gpd .GeoDataFrame :
489- """Remove small crowns that are largely contained within larger crowns.
490-
491- This is intended as a final cleanup step *after* clean_crowns and post_clean have run.
492- It catches nested duplicates that IoU-based cleaning misses: when a small crown sits inside
493- a much larger one, IoU is low (the union is dominated by the large crown) but the small
494- crown adds visual clutter rather than useful information.
495-
496- The larger crown is always kept. Only the smaller (contained) crown is removed.
497-
498- Args:
499- crowns (gpd.GeoDataFrame): Cleaned crowns to check for nesting.
500- containment_threshold (float, optional): Proportion of the smaller crown's area that must
501- fall inside the larger crown for it to be considered contained. Defaults to 0.85.
502- verbose (bool, optional): Print progress information. Defaults to True.
503-
504- Returns:
505- gpd.GeoDataFrame: Crowns with nested duplicates removed.
506- """
507- crowns = crowns .copy ()
508- crowns ["geometry" ] = crowns .geometry .buffer (0 )
509- crowns .reset_index (drop = True , inplace = True )
510-
511- # Spatial join to find candidate overlapping pairs
512- join = gpd .sjoin (crowns , crowns , how = "inner" , predicate = "intersects" )
513- join = join [join .index != join .index_right ]
514-
515- to_remove = set ()
516- for _ , row in tqdm (
517- join .iterrows (),
518- total = len (join ),
519- desc = "remove_contained_crowns: checking pairs" ,
520- smoothing = 0 ,
521- disable = not verbose ,
522- ):
523- i = row .name
524- j = row ["index_right" ]
525-
526- # Skip if either crown is already marked for removal
527- if i in to_remove or j in to_remove :
528- continue
529-
530- geom_i = crowns .at [i , "geometry" ]
531- geom_j = crowns .at [j , "geometry" ]
532-
533- # Determine which is smaller
534- if geom_i .area <= geom_j .area :
535- small_idx , small_geom , large_geom = i , geom_i , geom_j
536- else :
537- small_idx , small_geom , large_geom = j , geom_j , geom_i
538-
539- if small_geom .area == 0 :
540- to_remove .add (small_idx )
541- continue
542-
543- intersection_area = small_geom .intersection (large_geom ).area
544- containment_ratio = intersection_area / small_geom .area
545-
546- if containment_ratio > containment_threshold :
547- to_remove .add (small_idx )
548-
549- if verbose :
550- print (f"remove_contained_crowns: removed { len (to_remove )} nested crowns "
551- f"({ len (crowns )} → { len (crowns ) - len (to_remove )} )" )
552-
553- return crowns .drop (index = to_remove ).reset_index (drop = True )
554-
555-
556509def load_geopandas_dataframes (folder ):
557510 """Load all GeoPackage files in a folder into a list of GeoDataFrames."""
558511 all_files = glob .glob (f"{ folder } /*.gpkg" )
0 commit comments