Skip to content

Commit 1af7f9f

Browse files
PatBall1cursoragent
andcommitted
Add containment check to clean_crowns, remove remove_contained_crowns
Restores the containment check inside clean_crowns where it belongs. When a small crown is mostly inside a larger one (default 85%), the pair is merged and the most confident crown wins — exactly like IoU. This means: - Small confident crown inside large less-confident one → large one removed → post_clean fills the gap with other crowns - Small less-confident crown inside large confident one → small nested duplicate removed Removes the separate remove_contained_crowns function which wrongly always kept the larger crown regardless of confidence. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent fdb3973 commit 1af7f9f

1 file changed

Lines changed: 29 additions & 76 deletions

File tree

detectree2/models/outputs.py

Lines changed: 29 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -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-
556509
def 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

Comments
 (0)