Skip to content

Postprocessing

Functions for cleaning up raw detections: non-maximum suppression across and within tiles, polygon hole suppression, tile boundary suppression, masking, and filtering by canopy height. Most of these operate on the standardized RegionDetections and RegionDetectionsSet types.

postprocessing

Attributes

Classes

Functions:

NMS_on_points(detections, threshold_distance)

Run non-max suppression on point data, suppressing points within a thereshold distance of higher-score points

Parameters:

Name Type Description Default
detections Union[RegionDetections, RegionDetectionsSet]

The input data to run NMS on. Can either be a RegionDetections or RegionDetectionsSet object.

required
threshold_distance float

Detections within this distance of each other are suppressed

required

Returns:

Name Type Description
RegionDetections RegionDetections

The NMS-suppressed detections with all the original attributes

Source code in tree_detection_framework/postprocessing/postprocessing.py
def NMS_on_points(
    detections: Union[RegionDetections, RegionDetectionsSet], threshold_distance: float
) -> RegionDetections:
    """
    Run non-max suppression on point data, suppressing points within a thereshold distance of
    higher-score points

    Args:
        detections (Union[RegionDetections, RegionDetectionsSet]):
            The input data to run NMS on. Can either be a RegionDetections or RegionDetectionsSet
            object.
        threshold_distance (float):
            Detections within this distance of each other are suppressed

    Returns:
        RegionDetections:
            The NMS-suppressed detections with all the original attributes
    """

    # Define a function to buffer each point out by half the suppression distance
    def buffer_out(gdf: GeoDataFrame):
        copied_gdf = gdf.copy()
        # Buffer by half the distance because both objects will be buffered
        copied_gdf.geometry = copied_gdf.buffer(threshold_distance / 2)
        return copied_gdf

    # Buffer the points out to form circles of the desired radius
    # TODO Consider error checking that this is actually point-typed data
    buffered_points = detections.apply_function_to_detections(buffer_out, inplace=False)

    # Determine whether the single- or multi-region NMS function based on the type of the input data
    NMS_func = (
        multi_region_NMS
        if isinstance(detections, RegionDetectionsSet)
        else single_region_NMS
    )

    # Run NMS with the appropriate function. The theshold is set very low because we want any nonzero
    # overlap between the buffered circles to to count as an intersection, since that means the two
    # original centers are less than the threshold distance
    NMS_suppressed = NMS_func(buffered_points, threshold=1e-10, min_confidence=0)

    # We ran NMS on the buffered version of the data. Now, convert it back to a point representation.
    # Note: this whole process should run even if the input data was not points, but the result will
    # be points either way
    points = NMS_suppressed.apply_function_to_detections(update_gdf_to_centroid)

    return points

filter_by_chm(outputs, chm_path, min_height=10.0)

Filter tree detections by canopy height model (CHM) values.

A detection is kept if
  • max CHM value within geometry >= min_height, OR
  • the detection does not fully overlap the CHM

Parameters:

Name Type Description Default
outputs Union[RegionDetections, RegionDetectionsSet]

The detections to filter

required
chm_path str

Path to the canopy height model raster file

required
min_height float

Minimum height threshold for filtering detections

10.0

Returns:

Name Type Description
RegionDetections RegionDetections

Filtered detections that meet the height criteria

Source code in tree_detection_framework/postprocessing/postprocessing.py
def filter_by_chm(
    outputs: Union["RegionDetections", "RegionDetectionsSet"],
    chm_path: str,
    min_height: float = 10.0,
) -> "RegionDetections":
    """
    Filter tree detections by canopy height model (CHM) values.

    A detection is kept if:
        - max CHM value within geometry >= min_height, OR
        - the detection does not fully overlap the CHM

    Args:
        outputs (Union[RegionDetections, RegionDetectionsSet]):
            The detections to filter
        chm_path (str): Path to the canopy height model raster file
        min_height (float): Minimum height threshold for filtering detections

    Returns:
        RegionDetections: Filtered detections that meet the height criteria
    """

    if isinstance(outputs, RegionDetectionsSet):
        region_detections = outputs.merge()
    elif isinstance(outputs, RegionDetections):
        region_detections = outputs
    else:
        raise TypeError(
            f"Expected RegionDetections or RegionDetectionsSet, got {type(outputs)}"
        )

    detections_gdf = region_detections.get_data_frame()

    # Get valid CHM region (polygon of valid pixels)
    chm_valid_region = get_valid_raster_region(chm_path)

    with rasterio.open(chm_path) as src:
        chm_crs = src.crs
        nodata = src.nodata  # value used in raster to represent missing data

        # Reproject detections to match CHM CRS if needed
        if detections_gdf.crs != chm_crs:
            sampling_gdf = detections_gdf.to_crs(chm_crs)
        else:
            sampling_gdf = detections_gdf

        # Ensure CHM valid region is in same CRS as sampling_gdf
        if chm_valid_region.crs != sampling_gdf.crs:
            chm_valid_region = chm_valid_region.to_crs(sampling_gdf.crs)

        # Compute whether each detection is fully within CHM valid region
        within_mask = sampling_gdf.within(chm_valid_region.geometry.iloc[0])

        chm_heights = []

        # Find geometry type; note: this assumes all geometries are the same type
        geom_type = sampling_gdf.geometry.iloc[0].geom_type

        # 1. POINT CASE: sample CHM directly at point locations
        if geom_type == "Point":
            # Extract (x, y) coordinates for all points
            coords = [(geom.x, geom.y) for geom in sampling_gdf.geometry]

            # Batch sampling of raster values at point locations
            # Handles out-of-bounds safely by returning nodata value
            samples = np.array(list(src.sample(coords))).squeeze()

            # Identify which samples are valid CHM values
            if nodata is not None:
                valid_mask = samples != nodata
            else:
                valid_mask = ~np.isnan(samples)

            # Assign heights:
            # - valid values -> actual CHM height
            # - invalid/nodata -> np.nan (treated as "no CHM data")
            chm_heights = np.where(valid_mask, samples, np.nan)

        # 2. POLYGON / BBOX CASE: extract max CHM within geometry
        else:
            # Compute max CHM height per polygon
            stats = rasterstats.zonal_stats(
                sampling_gdf,
                chm_path,
                stats=["max"],
                nodata=nodata,
                geojson_out=False,
            )

            # Extract max values, handle None -> np.nan
            chm_heights = np.array(
                [stat["max"] if stat["max"] is not None else np.nan for stat in stats]
            )

    # Keep detections if:
    # - height >= threshold OR
    # - detection is NOT fully within CHM valid region
    keep_inds = np.where((chm_heights >= min_height) | (~within_mask))[0]

    return region_detections.subset_detections(keep_inds)

merge_and_postprocess_detections(detections, tolerance=0.2, min_area_threshold=20.0)

Apply postprocessing techniques that include: 1. Get a union of polygons that have been split across tiles 2. Simplify the edges of polygons by tolerance value 3. Remove holes within the polygons that are smaller than min_area_threshold value Merges regions into a single RegionDetections.

Parameters:

Name Type Description Default
detections RegionDetectionsSet

Detections from multiple regions to postprocess.

required
tolerance Optional[float]

A value that controls the simplification of the detection polygons. The higher this value, the smaller the number of vertices in the resulting geometry.

0.2
min_area_threshold Optional[float]

Holes within polygons having an area lesser than this value get removed.

20.0

Returns:

Name Type Description
RegionDetections RegionDetections

Postprocessed set of detections, merged together for the set of regions.

Source code in tree_detection_framework/postprocessing/postprocessing.py
def merge_and_postprocess_detections(
    detections: RegionDetectionsSet,
    tolerance: Optional[float] = 0.2,
    min_area_threshold: Optional[float] = 20.0,
) -> RegionDetections:
    """Apply postprocessing techniques that include:
    1. Get a union of polygons that have been split across tiles
    2. Simplify the edges of polygons by `tolerance` value
    3. Remove holes within the polygons that are smaller than `min_area_threshold` value
    Merges regions into a single RegionDetections.

    Args:
        detections(RegionDetectionsSet):
            Detections from multiple regions to postprocess.
        tolerance (Optional[float], optional):
            A value that controls the simplification of the detection polygons.
            The higher this value, the smaller the number of vertices in the resulting geometry.
        min_area_threshold (Optional[float], optional):
            Holes within polygons having an area lesser than this value get removed.

    Returns:
        RegionDetections:
            Postprocessed set of detections, merged together for the set of regions.
    """
    # Get the detections as a merged GeoDataFrame
    all_detections_gdf = detections.get_data_frame(merge=True)

    # Apply a small negative buffer to shrink polygons slightly
    buffered_geoms = [geom.buffer(-0.001) for geom in all_detections_gdf.geometry]

    # Compute the union of the set of polyogns. This step removes any vertical lines caused by the tile edges
    # and combines a single polygon that might have been split into multiple. Also removes any overlaps.
    union_detections = unary_union(buffered_geoms)

    # Simplify the polygons by tolerance value and extract only Polygons and MultiPolygons
    # since `union_detections` can have Point objects as well
    filtered_geoms = [
        geom.simplify(tolerance)
        for geom in list(union_detections.geoms)
        if isinstance(geom, (Polygon, MultiPolygon))
    ]

    # To remove small holes within polygons
    new_polygons = []
    for polygon in filtered_geoms:
        new_polygon = polygon_hole_suppression(polygon, min_area_threshold)
        new_polygons.append(new_polygon)

    # Create a RegionDetections for the merged and postprocessed detections
    # TODO: Handle cases when input is in pixels
    postprocessed_detections = RegionDetections(
        new_polygons, CRS=all_detections_gdf.crs
    )

    return postprocessed_detections

multi_region_NMS(detections, run_per_region_NMS=True, threshold=0.5, confidence_column='score', min_confidence=0.3, intersection_method='IOU')

Run non-max suppression on predictions from multiple regions.

Parameters:

Name Type Description Default
detections RegionDetectionsSet

Detections from multiple regions to run NMS on.

required
run_per_region_NMS bool

Should nonmax-suppression be run on each region before the regions are merged. This may lead to a speedup if there is a large amount of within-region overlap. Defaults to True.

True
threshold float

The threshold for the NMS(intersection) method. Defaults to 0.5.

0.5
confidence_column str

Which column in the dataframe to use as a confidence for NMS. Defaults to "score"

'score'
min_confidence float

Prediction score threshold for detections to be included.

0.3
intersection_method str

The method to compute intersections, one of ("IOU", "IOS", "Dice", "IOT"). Defaults to "IOU".

'IOU'

Returns: RegionDetections: NMS-suppressed set of detections, merged together for the set of regions.

Source code in tree_detection_framework/postprocessing/postprocessing.py
def multi_region_NMS(
    detections: RegionDetectionsSet,
    run_per_region_NMS: bool = True,
    threshold: float = 0.5,
    confidence_column: str = "score",
    min_confidence: float = 0.3,
    intersection_method: str = "IOU",
) -> RegionDetections:
    """Run non-max suppression on predictions from multiple regions.

    Args:
        detections (RegionDetectionsSet):
            Detections from multiple regions to run NMS on.
        run_per_region_NMS (bool):
            Should nonmax-suppression be run on each region before the regions are merged. This may
            lead to a speedup if there is a large amount of within-region overlap. Defaults to True.
        threshold (float, optional):
            The threshold for the NMS(intersection) method. Defaults to 0.5.
        confidence_column (str, optional):
            Which column in the dataframe to use as a confidence for NMS. Defaults to "score"

        min_confidence (float, optional):
            Prediction score threshold for detections to be included.
        intersection_method (str, optional):
            The method to compute intersections, one of ("IOU", "IOS", "Dice", "IOT"). Defaults to "IOU".
    Returns:
        RegionDetections:
            NMS-suppressed set of detections, merged together for the set of regions.
    """
    # Determine whether to run NMS individually on each region.
    if run_per_region_NMS:
        # Run NMS on each sub-region and then wrap this in a region detection set
        detections = RegionDetectionsSet(
            [
                single_region_NMS(
                    region_detections,
                    threshold=threshold,
                    confidence_column=confidence_column,
                    min_confidence=min_confidence,
                    intersection_method=intersection_method,
                )
                for region_detections in detections.region_detections
            ]
        )

    # Merge the detections into a single RegionDetections
    merged_detections = detections.merge()

    # If the bounds of the individual regions were disjoint, then no NMS needs to be applied across
    # the different regions
    if detections.disjoint_bounds():
        logging.info("Bounds are disjoint, skipping across-region NMS")
        return merged_detections
    logging.info("Bound have overlap, running across-region NMS")

    # Run NMS on this merged RegionDetections
    NMS_suppressed_merged_detections = single_region_NMS(
        merged_detections,
        threshold=threshold,
        confidence_column=confidence_column,
        min_confidence=min_confidence,
        intersection_method=intersection_method,
    )

    return NMS_suppressed_merged_detections

multi_region_hole_suppression(detections, min_area_threshold=20.0)

Suppress polygon holes in a RegionDetectionsSet object.

Parameters:

Name Type Description Default
detections RegionDetectionsSet

Set of detections from a multiple regions that need suppression of polygon holes.

required
min_area_threshold float

Remove holes within the polygons that have area smaller than this value.

20.0

Returns:

Name Type Description
RegionDetectionsSet

Set of detections after suppressing polygon holes.

Source code in tree_detection_framework/postprocessing/postprocessing.py
def multi_region_hole_suppression(
    detections: RegionDetectionsSet, min_area_threshold: float = 20.0
):
    """Suppress polygon holes in a RegionDetectionsSet object.

    Args:
        detections (RegionDetectionsSet):
            Set of detections from a multiple regions that need suppression of polygon holes.
        min_area_threshold(float):
            Remove holes within the polygons that have area smaller than this value.

    Returns:
        RegionDetectionsSet:
            Set of detections after suppressing polygon holes.
    """
    # Perform single_region_hole_suppression for every region within the RegionDetectionsSet
    return RegionDetectionsSet(
        [
            single_region_hole_suppression(region_detections, min_area_threshold)
            for region_detections in detections.region_detections
        ]
    )

polygon_hole_suppression(polygon, min_area_threshold=20.0)

To remove holes within a polygon

Parameters:

Name Type Description Default
polygon Polygon

A shapely polygon object

required
min_area_threshold float

Remove holes within the polygons that have area smaller than this value

20.0

Returns:

Type Description

shapely.Polygon: The equivalent polygon created after suppressing the holes

Source code in tree_detection_framework/postprocessing/postprocessing.py
def polygon_hole_suppression(polygon: Polygon, min_area_threshold: float = 20.0):
    """To remove holes within a polygon

    Args:
        polygon(shapely.Polygon):
            A shapely polygon object
        min_area_threshold(float):
            Remove holes within the polygons that have area smaller than this value

    Returns:
        shapely.Polygon:
            The equivalent polygon created after suppressing the holes
    """
    list_interiors = []
    # Iterate through interiors list which includes the holes
    for interior in polygon.interiors:
        interior_polygon = Polygon(interior)
        # If area of the hole is greater than the threshold, include it in the final output
        if interior_polygon.area > min_area_threshold:
            list_interiors.append(interior)

    # Return a new polygon with holes suppressed
    return Polygon(polygon.exterior.coords, holes=list_interiors)

remove_detections_from_tile_overlap(detections, retain_method='within')

Remove detections in overlapping regions of tiles by defining a central "core" area of each tile which is farthest from the boundary and keeping detections within that region.

Parameters:

Name Type Description Default
detections RegionDetectionsSet

The detections to suppress

required
retain_method str

How to deal with edge detections. "within" keeps detections that are fully within the interior region, "intersects" keeps any that touch the interior region, and "clip" clips detections on the boundary so only the portion within the interior is retained. Defaults to "within".

'within'

Returns:

Name Type Description
RegionDetectionsSet RegionDetectionsSet

Updated detections

Source code in tree_detection_framework/postprocessing/postprocessing.py
def remove_detections_from_tile_overlap(
    detections: RegionDetectionsSet,
    retain_method: str = "within",
) -> RegionDetectionsSet:
    """Remove detections in overlapping regions of tiles by defining a central "core" area of each
    tile which is farthest from the boundary and keeping detections within that region.

    Args:
        detections (RegionDetectionsSet):
            The detections to suppress
        retain_method (str, optional):
            How to deal with edge detections. "within" keeps detections that are fully within the
            interior region, "intersects" keeps any that touch the interior region, and "clip" clips
            detections on the boundary so only the portion within the interior is retained. Defaults
            to "within".

    Returns:
        RegionDetectionsSet: Updated detections
    """
    updated_rd_list = []
    # Get the bounds of each tile
    detection_bounds = detections.get_bounds(union_bounds=False).tolist()
    # Find the core nonoverlapping region of each tile
    core_detection_bounds = make_polygon_set_nonoverlapping(detection_bounds)

    for rd, core_bounds in zip(detections.region_detections, core_detection_bounds):

        def update_detections(detections: gpd.GeoDataFrame):
            if retain_method == "within":
                # Keep detections that are fully within the interior region
                updated_detections = detections[detections.within(core_bounds)]
            elif retain_method == "intersects":
                # Keep detections that intersect the interior region
                updated_detections = detections[detections.intersects(core_bounds)]
            elif retain_method == "clip":
                # Clip detections to the interior region, entirely dropping empty geometries
                updated_detections = detections.clip(core_bounds)
            else:
                raise ValueError(
                    f"retain_method was {retain_method}, but should be 'within', 'intersects', or 'clip'"
                )

            return updated_detections

        # Apply the function to the detections DataFrame to suppress detections in the edge region
        updated_rd = rd.apply_function_to_detections(update_detections)
        # Record the result
        updated_rd_list.append(updated_rd)

    # Create a new RDS
    updated_rds = RegionDetectionsSet(updated_rd_list)
    return updated_rds

remove_masked_detections(region_detection_sets, mask_iterator, threshold=0.4)

Filters out detections that marked as invalid in the given masks.

Returns:

Type Description
List[RegionDetectionsSet]

List of RegiondetectionSet objects with masked predictions filtered out (one per

List[RegionDetectionsSet]

image) OR list of geospatial dataframes (one per image). This is based on the

List[RegionDetectionsSet]

input type of region_detection_sets

Source code in tree_detection_framework/postprocessing/postprocessing.py
def remove_masked_detections(
    region_detection_sets: Union[List[RegionDetectionsSet], List[PATH_TYPE]],
    mask_iterator: Iterable[np.ndarray],
    threshold: float = 0.4,
) -> List[RegionDetectionsSet]:
    """
    Filters out detections that marked as invalid in the given masks.

    Args:
        region_detection_sets (Union[List[RegionDetectionSet], List[PATH_TYPE]])
            Each element is a RegionDetectionsSet derived from a specific drone image,
            or a geospatial file containing the detections from a drone image.
            Length is the number of raw drone images given to  the dataloader.
        mask_iterator (Iterable[np.ndarray])
            Iterator of masks of shape (height, width), dtype bool. Should be the
            same length as the region_detection_sets. The areas in which we wish to
            keep detections should be True. In order to be effective, these should be
            the same size as the images the detections were created from.
        threshold (float)
            If the overlap of a given detection polygon with "valid" portions of the
            mask is greater than the threshold, keep that detection. If not, filter
            it out. Defaults to 0.4.

    Returns:
        List of RegiondetectionSet objects with masked predictions filtered out (one per
        image) OR list of geospatial dataframes (one per image). This is based on the
        input type of region_detection_sets
    """

    filtered_sets = []

    # Iterate over the RegionDetectionsSet objects
    for rds, mask in zip(region_detection_sets, mask_iterator):

        # Define a transformation from the image space ([0, 0] at the top left,
        # y increases going down) to rasterstats ([0, 0] at the bottom left,
        # y increases going up)
        # Note that there is some bizzare and unfortunate interplay between
        # The transform and the orientation of the mask image. If the mask and gdf
        # are both defined in pure pixels and the transform is Identity, then
        # rasterstats complains about negative window sizes. If the transform is
        # such that Y is in the upper left and increases going down, then the
        # pixel polygons appear flipped on Y (0:10, 0:10) gives the lower left
        # corner of the mask, which is incorrect). Only by defining this axis
        # flipping transform AND inverting the Y axis of the mask with [::-1, ...]
        # can we get alignment and avoid the negative window error.
        transform = Affine.translation(0, mask.shape[0]) * Affine.scale(1, -1)
        # Make the mask integer so that we can do math on the detections.
        # For example, if a detection is 50+% valid, the mean value within
        # the polygon will be 0.5+ because the mask is in numbers.
        mask = mask.astype(int)[::-1, ...]

        # To save filtered region detections in a particular set
        filtered_regions = []

        # We should either have a RegionDetectionsSet class, or a file path
        # to a dataframe containing the detections
        if isinstance(rds, RegionDetectionsSet):

            def iterator(rds):
                for idx in range(len(rds.region_detections)):
                    rd = rds.get_region_detections(idx)
                    yield idx, rd.get_data_frame()

        else:

            def iterator(rds):
                yield 0, gpd.read_file(rds)

        # Iterate over the region detections, which is equivalent to iterating
        # over the chips that detections were calculated in
        for idx, gdf in iterator(rds):

            # Get the mean value of each detection polygon, as a list of
            # [{"mean": <value>}, ...]
            stats = rasterstats.zonal_stats(gdf, mask, stats=["mean"], affine=transform)

            # Get indices that have a greater fraction of good pixels than the
            # threshold requires.
            good_indices = [
                i for i, stat in enumerate(stats) if stat["mean"] > threshold
            ]

            if isinstance(rds, RegionDetectionsSet):
                # Subset the RegionDetections object keeping only the valid indices
                rd = rds.get_region_detections(idx)
                filtered_rd = rd.subset_detections(good_indices)
                filtered_regions.append(filtered_rd)
            else:
                # Take a subset of the dataframe rows that we know are valid
                filtered_regions.append(gdf.iloc[good_indices])

        if isinstance(rds, RegionDetectionsSet):
            # If we were passed a list of RegionDetectionsSet items, then we want
            # to recreate that for the output with filtered RDS items
            filtered_sets.append(RegionDetectionsSet(filtered_regions))
        else:
            # Otherwise, if we were passed a list of GDF paths, return a list
            # of filtered GDF items
            filtered_sets.extend(filtered_regions)

    return filtered_sets

remove_maskfile_detections(region_detection_sets, image_root, image_paths, valid_classes, mask_root, mask_extension='.png', threshold=0.4)

Filters out detections that marked as invalid in the given mask images. One example use case is that mask images could be rastered where the certain pixels are marked as "ground" vs. "above ground". Then detections that mostly overlap with the "ground" mask could be removed.

Returns:

Type Description
Union[List[RegionDetectionsSet], List[GeoDataFrame]]

List of RegiondetectionSet objects with masked predictions filtered out (one per

Union[List[RegionDetectionsSet], List[GeoDataFrame]]

image) OR list of geospatial dataframes (one per image). This is based on the

Union[List[RegionDetectionsSet], List[GeoDataFrame]]

input type of region_detection_sets

Source code in tree_detection_framework/postprocessing/postprocessing.py
def remove_maskfile_detections(
    region_detection_sets: Union[List[RegionDetectionsSet], List[PATH_TYPE]],
    image_root: PATH_TYPE,
    image_paths: List[PATH_TYPE],
    valid_classes: List[int],
    mask_root: PATH_TYPE,
    mask_extension: str = ".png",
    threshold: float = 0.4,
) -> Union[List[RegionDetectionsSet], List[gpd.GeoDataFrame]]:
    """
    Filters out detections that marked as invalid in the given mask images.
    One example use case is that mask images could be rastered where the
    certain pixels are marked as "ground" vs. "above ground". Then detections
    that mostly overlap with the "ground" mask could be removed.

    Args:
        region_detection_sets (Union[List[RegionDetectionSet], List[PATH_TYPE]])
            Each element is a RegionDetectionsSet derived from a specific drone image,
            or a geospatial file containing the detections from a drone image.
            Length is the number of raw drone images given to  the dataloader.
        image_root (PATH_TYPE)
            Path to the root directory for image paths, which should match up with
            the region_detection_sets. The point is that we want to open up a mask
            file per RDS, and we assume that the path from image_root → image path
            is the same as mask_root → mask path, with suffix change.
        image_paths (List[PATH_TYPE])
            Path to each image that the RDS objects were created from
        valid_classes (List[int])
            Assuming the mask file is an image with pixels as class integers, this is a
            list of classes that we want to keep. For example, if (0: out of bounds,
            1: ground, 2: shrubs, 3: trees) and we wanted to keep all foliage, this
            argument should be [2, 3].
        mask_root (PATH_TYPE)
            Path to root directory for mask files, see image_root.
        mask_extension (str)
            File extension for mask images. Defaults to ".png".
        threshold (float)
            If the overlap of a given detection polygon with "valid" portions of the
            mask is greater than the threshold, keep that detection. If not, filter
            it out. Defaults to 0.4.

    Returns:
        List of RegiondetectionSet objects with masked predictions filtered out (one per
        image) OR list of geospatial dataframes (one per image). This is based on the
        input type of region_detection_sets
    """

    # Input checking
    if len(image_paths) != len(region_detection_sets):
        raise ValueError(
            f"Mismatch between length of given image_paths ({len(image_paths)})"
            f" and region detection sets ({len(region_detection_sets)})"
        )

    def mask_iterator():
        """Helper function to generate the masks from file paths."""

        # Iterate over each path to a specific image
        for im_path in image_paths:

            # Assuming the mask path relative to the mask root matches the
            # image path relative to the image root, open the mask file
            subpath = Path(im_path).relative_to(image_root)
            mask_path = (mask_root / subpath).with_suffix(mask_extension)

            # Calculate a mask which is True where the data is valid, a.k.a.
            # in the parts of the image we think could contain good detections.
            # Open the image as grayscale
            mask_img = Image.open(mask_path).convert("L")
            mask = np.isin(mask_img, valid_classes)

            yield mask

    return remove_masked_detections(
        region_detection_sets=region_detection_sets,
        mask_iterator=mask_iterator(),
        threshold=threshold,
    )

remove_out_of_bounds_detections(region_detection_sets, image_bounds)

Filters out detections that are outside the bounds of a defined region. Used as a post-processing step after predict_raw_drone_images().

Parameters:

Name Type Description Default
region_detection_sets List[RegionDetectionSet]

Each elemet is a RegionDetectionsSet derived from a specific drone image. Length is the number of raw drone images given to the dataloader.

required
image_bounds List[bounding_box]

Each element is a bounding_box object derived from the dataloader. Length: number of regions in a set * number of RegionDetectionSet objects

required

Returns:

Type Description
List[RegionDetectionsSet]

List of RegiondetectionSet objects with out-of-bounds predictions filtered out.

Source code in tree_detection_framework/postprocessing/postprocessing.py
def remove_out_of_bounds_detections(
    region_detection_sets: List[RegionDetectionsSet], image_bounds: List
) -> List[RegionDetectionsSet]:
    """
    Filters out detections that are outside the bounds of a defined region.
    Used as a post-processing step after `predict_raw_drone_images()`.

    Args:
        region_detection_sets (List[RegionDetectionSet]):
            Each elemet is a RegionDetectionsSet derived from a specific drone image.
            Length is the number of raw drone images given to  the dataloader.
        image_bounds (List[bounding_box]):
            Each element is a `bounding_box` object derived from the dataloader.
            Length: number of regions in a set * number of RegionDetectionSet objects

    Returns:
        List of RegiondetectionSet objects with out-of-bounds predictions filtered out.
    """

    region_idx = 0  # To index elements in true_bounds
    list_of_filtered_region_sets = []

    for rds in region_detection_sets:

        # Find the number of regions in every set
        num_of_regions_in_a_set = len(rds.get_data_frame())
        # To save filtered regions in a particular set
        list_of_filtered_regions = []

        # Get the region image bounds for the RegionDetectionSet
        region_image_bounds = image_bounds[region_idx]

        for idx in range(num_of_regions_in_a_set):

            # Get RegionsDetections object from the set
            rd = rds.get_region_detections(idx)
            rd_gdf = rd.get_data_frame()

            # Create a polygon of size equal to the image dimensions
            region_set_polygon = box(
                region_image_bounds.minx,
                region_image_bounds.maxy,
                region_image_bounds.maxx,
                region_image_bounds.miny,
            )

            # TODO: Instead of removing detections partially extending out of the boundary,
            # try cropping it using gpd.clip()
            # Remove detections that extend beyond the image bounds
            within_bounds_indices = rd_gdf.within(region_set_polygon)
            within_bounds_indices_true = within_bounds_indices[
                within_bounds_indices
            ].index

            # Subset the RegionDetections object keeping only the valid indices calculated before
            filtered_rd = rd.subset_detections(within_bounds_indices_true)
            list_of_filtered_regions.append(filtered_rd)

        list_of_filtered_region_sets.append(
            RegionDetectionsSet(list_of_filtered_regions)
        )

        # Update region_idx to point to the image dims of the next rds
        region_idx += num_of_regions_in_a_set

    return list_of_filtered_region_sets

single_region_NMS(detections, threshold=0.5, confidence_column='score', min_confidence=0.3, intersection_method='IOU')

Run non-max suppression on predictions from a single region.

Parameters:

Name Type Description Default
detections RegionDetections

Detections from a single region to run NMS on.

required
threshold float

The threshold for the NMS(intersection) method. Defaults to 0.5.

0.5
confidence_column str

Which column in the dataframe to use as a confidence for NMS. Defaults to "score"

'score'
min_confidence float

Prediction score threshold for detections to be included.

0.3
intersection_method str

The method to compute intersections, one of ("IOU", "IOS", "Dice", "IOT"). Defaults to "IOU".

'IOU'

Returns:

Name Type Description
RegionDetections RegionDetections

NMS-suppressed set of detections

Source code in tree_detection_framework/postprocessing/postprocessing.py
def single_region_NMS(
    detections: RegionDetections,
    threshold: float = 0.5,
    confidence_column: str = "score",
    min_confidence: float = 0.3,
    intersection_method: str = "IOU",
) -> RegionDetections:
    """Run non-max suppression on predictions from a single region.

    Args:
        detections (RegionDetections):
            Detections from a single region to run NMS on.
        threshold (float, optional):
            The threshold for the NMS(intersection) method. Defaults to 0.5.
        confidence_column (str, optional):
            Which column in the dataframe to use as a confidence for NMS. Defaults to "score"
        min_confidence (float, optional):
            Prediction score threshold for detections to be included.
        intersection_method (str, optional):
            The method to compute intersections, one of ("IOU", "IOS", "Dice", "IOT"). Defaults to "IOU".

    Returns:
        RegionDetections:
            NMS-suppressed set of detections
    """
    # Extract the geodataframe for the detections
    detections_df = detections.get_data_frame()

    # Buffer by zero to fix any issues with invalid geometries
    detections_df["geometry"] = detections_df.geometry.buffer(0)

    # Determine which detections are high enough confidence to retain
    # Get rows that are both high confidence and not empty
    not_empty_mask = ~detections_df.geometry.is_empty
    high_conf_not_empty_inds = np.where(
        (
            (detections_df[confidence_column] >= min_confidence) & not_empty_mask
        ).to_numpy()
    )[0]

    # Filter detections based on minimum confidence score
    detections_df = detections_df.iloc[high_conf_not_empty_inds]
    if detections_df.empty:
        # Return empty if no detections pass threshold
        return detections.subset_detections([])

    ## Get the polygons for each detection object
    polygons = detections_df.geometry.to_list()
    # Extract the score
    confidences = detections_df[confidence_column].to_numpy()

    # Put the data into the required format, list[(polygon, class, confidence)]
    # TODO consider adding a class, currently set to all ones
    input_data = list(zip(polygons, np.ones_like(confidences), confidences))

    # Run polygon NMS
    keep_inds = nms(
        input_data=input_data,
        distributed=None,
        nms_method="Default",
        intersection_method=intersection_method,
        threshold=threshold,
    )

    # We only performed NMS on the high-confidence detections, but we need the indices w.r.t. the
    # original data with all detections. Sort for convenience so data is not permuted.
    keep_inds_in_original = sorted(high_conf_not_empty_inds[keep_inds])
    # Extract the detections that were kept
    subset_region_detections = detections.subset_detections(keep_inds_in_original)

    return subset_region_detections

single_region_hole_suppression(detections, min_area_threshold=20.0)

Suppress polygon holes in a RegionDetections object.

Parameters:

Name Type Description Default
detections RegionDetections

Detections from a single region that needs suppression of polygon holes.

required
min_area_threshold float

Remove holes within the polygons that have area smaller than this value.

20.0

Returns:

Name Type Description
RegionDetections

Detections after suppressing polygon holes.

Source code in tree_detection_framework/postprocessing/postprocessing.py
def single_region_hole_suppression(
    detections: RegionDetections, min_area_threshold: float = 20.0
):
    """Suppress polygon holes in a RegionDetections object.

    Args:
        detections (RegionDetections):
            Detections from a single region that needs suppression of polygon holes.
        min_area_threshold(float):
            Remove holes within the polygons that have area smaller than this value.

    Returns:
        RegionDetections:
            Detections after suppressing polygon holes.
    """
    detections_df = detections.get_data_frame()
    modified_geometries = []

    for tree_crown in detections_df.geometry.to_list():
        # If tree_crown is a Polygon, directly do polygon hole suppression
        if isinstance(tree_crown, Polygon):
            clean_tree_crown = polygon_hole_suppression(tree_crown, min_area_threshold)
        # If it is a MultiPolygon, do polygon hole suppression for each polygon within it
        elif isinstance(tree_crown, MultiPolygon):
            clean_polygons = []
            for polygon in tree_crown.geoms:
                clean_polygon = polygon_hole_suppression(polygon, min_area_threshold)
                clean_polygons.append(clean_polygon)
            # Create a new MultiPolygon with the suppressed polygons
            clean_tree_crown = MultiPolygon(clean_polygons)
        # For any other cases, create an empty polygon (just to be safe)
        else:
            clean_tree_crown = Polygon()

        # Add the cleaned polygon/multipolygon to a list
        modified_geometries.append(clean_tree_crown)

    # Set this list as the geometry column in the dataframe
    detections_df.geometry = modified_geometries
    # Return a new RegionDetections object created using the updated dataframe
    # TODO: Handle cases where the data is in pixels with no transform to geospatial
    return RegionDetections(
        detection_geometries=None, data=detections_df, CRS=detections.get_CRS()
    )

suppress_tile_boundary_with_NMS(predictions, iou_threshold=0.5, ios_threshold=0.5, min_confidence=0.3)

Used as a post-processing step with the GeometricDetector class to suppress detections that are split across tiles. This is done by applying NMS twice, first using IOU and then using IOS.

Parameters:

Name Type Description Default
predictions RegionDetectionsSet

Detections from multiple regions.

required
iou_threshold float

The threshold for the NMS method that uses IoU metric. Defaults to 0.5.

0.5
ios_threshold float

The threshold for the NMS method that uses IoS metric. Defaults to 0.5.

0.5
min_confidence float

Prediction score threshold for detections to be included.

0.3

Returns:

Name Type Description
RegionDetections RegionDetections

NMS postprocessed set of detections, merged together.

Source code in tree_detection_framework/postprocessing/postprocessing.py
def suppress_tile_boundary_with_NMS(
    predictions: RegionDetectionsSet,
    iou_threshold: float = 0.5,
    ios_threshold: float = 0.5,
    min_confidence: float = 0.3,
) -> RegionDetections:
    """
    Used as a post-processing step with the `GeometricDetector` class to suppress detections that are split across tiles.
    This is done by applying NMS twice, first using IOU and then using IOS.

    Args:
        predictions (RegionDetectionsSet):
            Detections from multiple regions.
        iou_threshold (float, optional):
            The threshold for the NMS method that uses IoU metric. Defaults to 0.5.
        ios_threshold (float, optional):
            The threshold for the NMS method that uses IoS metric. Defaults to 0.5.
        min_confidence (float, optional):
            Prediction score threshold for detections to be included.

    Returns:
        RegionDetections:
            NMS postprocessed set of detections, merged together.
    """

    iou_nms = multi_region_NMS(
        predictions,
        intersection_method="IOU",
        threshold=iou_threshold,
        min_confidence=min_confidence,
    )

    iou_ios_nms = single_region_NMS(
        iou_nms,
        intersection_method="IOS",
        threshold=ios_threshold,
        min_confidence=min_confidence,
    )

    return iou_ios_nms

update_gdf_to_centroid(gdf)

Return the geodataframe with the geometry updated to be the centroid Point

Parameters:

Name Type Description Default
gdf GeoDataFrame

A dataframe to convert

required

Returns:

Name Type Description
GeoDataFrame GeoDataFrame

the input geodataframe with the geometry updated to be the centroid Point

Source code in tree_detection_framework/postprocessing/postprocessing.py
def update_gdf_to_centroid(gdf: GeoDataFrame) -> GeoDataFrame:
    """Return the geodataframe with the geometry updated to be the centroid Point

    Args:
        gdf (GeoDataFrame): A dataframe to convert

    Returns:
        GeoDataFrame: the input geodataframe with the geometry updated to be the centroid Point
    """
    # Copy the data to avoid updating the orignal
    copied_gdf = gdf.copy()
    # Set the geometry column to the centroid
    copied_gdf.geometry = gdf.centroid

    return copied_gdf