Skip to content

Region detections

region_detections

Attributes

Classes

RegionDetections

Source code in tree_detection_framework/detection/region_detections.py
class RegionDetections:
    detections: gpd.GeoDataFrame
    prediction_bounds_in_CRS: Union[shapely.Polygon, shapely.MultiPolygon, None]

    def __init__(
        self,
        detection_geometries: List[shapely.Geometry] | str,
        data: Union[dict, pd.DataFrame] = {},
        CRS: Optional[Union[pyproj.CRS, rasterio.CRS]] = None,
        geospatial_prediction_bounds: Optional[
            shapely.Polygon | shapely.MultiPolygon
        ] = None,
        geometry_columns: Optional[List[str]] = None,
    ):
        """Create a region detections object

        Args:
            detection_geometries (List[shapely.Geometry] | str | None):
                A list of shapely geometries for each detection. Alternatively, can be a string
                represting a key in data providing the same, or None if that key is named "geometry".
            data (Optional[dict | pd.DataFrame], optional):
                A dictionary mapping from str names for an attribute to a list of values for that
                attribute, one value per detection. Or a pandas dataframe. Passed to the data
                argument of gpd.GeoDataFrame. Defaults to {}.
            CRS (Optional[pyproj.CRS], optional):
                A coordinate reference system to interpret the data in. TODO, describe what happens
                if this is not set. Defaults to None.
            geometry_columns (Optional[List[str]], optional):
                Which columns to treat as geometric columns that should be transformed to different
                reference frames. If unset, all geometry-typed columns will be transformed. Defaults
                to None.
        """
        # Build a geopandas dataframe containing the geometries, additional attributes, and CRS
        self.detections = gpd.GeoDataFrame(
            data=data, geometry=detection_geometries, crs=CRS
        )

        # If no bounds are provided, compute them from the detections if possible
        if geospatial_prediction_bounds is None and len(self.detections) > 0:
            geospatial_prediction_bounds = self.detections.total_bounds
            geospatial_prediction_bounds = shapely.box(*geospatial_prediction_bounds)

        # Create a one-length geoseries for the bounds
        self.prediction_bounds_in_CRS = gpd.GeoSeries(
            data=[geospatial_prediction_bounds], crs=CRS
        )

        # Record which columns contain geometry attributes
        # TODO: if not specified, set it to all columns that are of type BaseGeometry
        self.geometry_columns = geometry_columns

    def apply_function_to_detections(
        self,
        func: Callable[[gpd.GeoDataFrame], gpd.GeoDataFrame],
        inplace: bool = False,
    ) -> Optional["RegionDetections"]:
        """_summary_

        Args:
            func (Callable[[gpd.GeoDataFrame], gpd.GeoDataFrame]):
                A function which takes in a GeoDataFrame and returns another GeoDataFrame.
            inplace (bool):
                Should the RegionDetections object be modified inplace, or a modified copy returned.
                Defaults to False.

        Returns:
            Optional[RegionDetections]:
                If inplace=False, returns a modified copy. Otherwise, returns None.
        """
        if inplace:
            # If inplace, just modify the detections directly
            self.detections = func(self.detections)
        else:
            # If not inplace, first deepcopy self to avoid any chance of modifying the original
            # .detections object
            modified_rd = copy.deepcopy(self)
            # Then apply the function
            modified_rd.detections = func(modified_rd.detections)

            return modified_rd

    def subset_detections(self, detection_indices) -> "RegionDetections":
        """Return a new RegionDetections object with only the detections indicated by the indices

        Args:
            detection_indices:
                Which detections to include. Can be any type that can be passed to pd.iloc.

        Returns:
            RegionDetections: The subset of detections cooresponding to these indeices
        """
        # Create a deep copy of the object
        subset_rd = copy.deepcopy(self)
        # Subset the detections dataframe to the requested rows
        subset_rd.detections = subset_rd.detections.iloc[detection_indices, :]

        return subset_rd

    def save(self, save_path: PATH_TYPE):
        """Saves the information to disk

        Args:
            save_path (PATH_TYPE):
                Path to a geofile to save the data to. The containing folder will be created if it
                doesn't exist.
        """
        # Convert to a Path object and create the containing folder if not present
        save_path = Path(save_path)
        save_path.parent.mkdir(parents=True, exist_ok=True)

        # Save the detections to a file. Note that the bounds of the prediction region are currently lost.
        self.detections.to_file(save_path)

    def get_data_frame(self, CRS: Optional[pyproj.CRS] = None) -> gpd.GeoDataFrame:
        """Get the detections, optionally specifying a CRS

        Args:
            CRS (Optional[pyproj.CRS], optional):
                Requested CRS for the output detections. If un-set, the CRS of self.detections will
                be used. Defaults to None.

        Returns:
            gpd.GeoDataFrame: Detections in the requested CRS
        """
        # If no CRS is specified, return the data as-is, using the current CRS
        if CRS is None:
            return self.detections.copy()

        # Transform the data to the requested CRS. Note that if no CRS is provided initially,
        # this will error out. This is applied to all geometry-typed columns unless explicitly
        # stated by self.geometry_columns
        detections_in_new_CRS = to_crs_multiple_geometry_columns(
            self.detections,
            crs=CRS,
            columns_to_transform=self.geometry_columns,
            inplace=False,
        )
        return detections_in_new_CRS

    def convert_to_bboxes(self) -> "RegionDetections":
        """
        Return a copy of the RD with the geometry of all detections replaced by the minimum
        axis-aligned bounding rectangle. Also, rows with an empty geometry are removed.

        Returns:
            RegionDetections: An identical RD with all non-empty geometries represented as bounding boxes.
        """
        # Get the detections
        detections_df = self.get_data_frame()
        # Get non-empty rows in the dataframe, since conversion to bounding box only works for
        # non-empty polygons
        nonempty_rows = detections_df[~detections_df.geometry.is_empty]
        # Get the bounds and convert to shapely boxes
        bounds = nonempty_rows.bounds
        boxes = shapely.box(
            xmin=bounds.minx, ymin=bounds.miny, xmax=bounds.maxx, ymax=bounds.maxy
        )
        # Update the geometry
        # TODO make sure that thisn't updating the geometry of the orignal one
        nonempty_rows.geometry = boxes
        # Create a new RegionDetections object and update the detections on it
        bbox_rd = copy.deepcopy(self)
        bbox_rd.detections = nonempty_rows
        return bbox_rd

    def update_geometry_column(self, geometry_column: str) -> "RegionDetections":
        """Update the geometry to another column in the dataframe that contains shapely data

        Args:
            geometry_column (str): The name of a column containing shapely data

        Returns:
            RegionDetections: An updated RD with the geometry specified by the data in `geometry_column`
        """
        # Create a copy of the detections
        detections_df = self.get_data_frame().copy()
        # Set the geometry column to the specified one
        detections_df.geometry = detections_df[geometry_column]

        # Create a copy of the RD
        updated_geometry_rd = copy.deepcopy(self)
        # Update the detections and return
        updated_geometry_rd.detections = detections_df

        return updated_geometry_rd

    def get_bounds(self, CRS: Optional[pyproj.CRS] = None) -> gpd.GeoSeries:
        if CRS is None:
            # Get bounds in original CRS
            bounds = self.prediction_bounds_in_CRS.copy()
        else:
            # Get bounds in requested CRS
            bounds = self.prediction_bounds_in_CRS.to_crs(CRS)

        return bounds

    def get_CRS(self) -> Union[pyproj.CRS, None]:
        """Return the CRS of the detections dataframe

        Returns:
            Union[pyproj.CRS, None]: The CRS for the detections
        """
        return self.detections.crs

    def plot(
        self,
        CRS: Optional[pyproj.CRS] = None,
        plt_ax: Optional[plt.axes] = None,
        plt_show: bool = True,
        show_centroid: bool = False,
        visualization_column: Optional[str] = None,
        bounds_color: Optional[Union[str, np.array, pd.Series]] = None,
        detection_kwargs: dict = {},
        bounds_kwargs: dict = {},
        raster_file: Optional[PATH_TYPE] = None,
        raster_vis_downsample: float = 10.0,
    ) -> plt.axes:
        """Plot the detections and the bounds of the region

        Args:
            CRS (Optional[pyproj.CRS], optional):
                What CRS to use. Defaults to None.
            plt_ax (Optional[plt.axes], optional):
                A pyplot axes to plot on. If not provided, one will be created. Defaults to None.
            plt_show (bool, optional):
                Whether to plot the result or just return it. Defaults to True.
            show_centroid (bool, optional):
                Whether to plot the centroid points within the boundaries. Defaults to False.
            visualization_column (Optional[str], optional):
                Which column to visualize from the detections dataframe. Defaults to None.
            bounds_color (Optional[Union[str, np.array, pd.Series]], optional):
                The color to plot the bounds. Must be accepted by the gpd.plot color argument.
                Defaults to None.
            detection_kwargs (dict, optional):
                Additional keyword arguments to pass to the .plot method for the detections.
                Defaults to {}.
            bounds_kwargs (dict, optional):
                Additional keyword arguments to pass to the .plot method for the bounds.
                Defaults to {}.
            raster_file (Optional[PATH_TYPE], optional):
                A path to a raster file to visualize the detections over if provided. Defaults to None.
            raster_vis_downsample (float, optional):
                The raster file is downsampled by this fraction before visualization to avoid
                excessive memory use or plotting time. Defaults to 10.0.

        Returns:
            plt.axes: The axes that were plotted on
        """

        # Get the dataframe and the bounds
        data_frame = self.get_data_frame(CRS=CRS)
        bounds = self.get_bounds(CRS)

        # Perform plotting and return the axes
        return plot_detections(
            data_frame=data_frame,
            bounds=bounds,
            CRS=data_frame.crs,
            plt_ax=plt_ax,
            plt_show=plt_show,
            show_centroid=show_centroid,
            visualization_column=visualization_column,
            detection_kwargs=detection_kwargs,
            bounds_kwargs=bounds_kwargs,
            raster_file=raster_file,
            raster_vis_downsample=raster_vis_downsample,
            bounds_color=bounds_color,
        )
Methods:
__init__(detection_geometries, data={}, CRS=None, geospatial_prediction_bounds=None, geometry_columns=None)

Create a region detections object

Parameters:

Name Type Description Default
detection_geometries List[Geometry] | str | None

A list of shapely geometries for each detection. Alternatively, can be a string represting a key in data providing the same, or None if that key is named "geometry".

required
data Optional[dict | DataFrame]

A dictionary mapping from str names for an attribute to a list of values for that attribute, one value per detection. Or a pandas dataframe. Passed to the data argument of gpd.GeoDataFrame. Defaults to {}.

{}
CRS Optional[CRS]

A coordinate reference system to interpret the data in. TODO, describe what happens if this is not set. Defaults to None.

None
geometry_columns Optional[List[str]]

Which columns to treat as geometric columns that should be transformed to different reference frames. If unset, all geometry-typed columns will be transformed. Defaults to None.

None
Source code in tree_detection_framework/detection/region_detections.py
def __init__(
    self,
    detection_geometries: List[shapely.Geometry] | str,
    data: Union[dict, pd.DataFrame] = {},
    CRS: Optional[Union[pyproj.CRS, rasterio.CRS]] = None,
    geospatial_prediction_bounds: Optional[
        shapely.Polygon | shapely.MultiPolygon
    ] = None,
    geometry_columns: Optional[List[str]] = None,
):
    """Create a region detections object

    Args:
        detection_geometries (List[shapely.Geometry] | str | None):
            A list of shapely geometries for each detection. Alternatively, can be a string
            represting a key in data providing the same, or None if that key is named "geometry".
        data (Optional[dict | pd.DataFrame], optional):
            A dictionary mapping from str names for an attribute to a list of values for that
            attribute, one value per detection. Or a pandas dataframe. Passed to the data
            argument of gpd.GeoDataFrame. Defaults to {}.
        CRS (Optional[pyproj.CRS], optional):
            A coordinate reference system to interpret the data in. TODO, describe what happens
            if this is not set. Defaults to None.
        geometry_columns (Optional[List[str]], optional):
            Which columns to treat as geometric columns that should be transformed to different
            reference frames. If unset, all geometry-typed columns will be transformed. Defaults
            to None.
    """
    # Build a geopandas dataframe containing the geometries, additional attributes, and CRS
    self.detections = gpd.GeoDataFrame(
        data=data, geometry=detection_geometries, crs=CRS
    )

    # If no bounds are provided, compute them from the detections if possible
    if geospatial_prediction_bounds is None and len(self.detections) > 0:
        geospatial_prediction_bounds = self.detections.total_bounds
        geospatial_prediction_bounds = shapely.box(*geospatial_prediction_bounds)

    # Create a one-length geoseries for the bounds
    self.prediction_bounds_in_CRS = gpd.GeoSeries(
        data=[geospatial_prediction_bounds], crs=CRS
    )

    # Record which columns contain geometry attributes
    # TODO: if not specified, set it to all columns that are of type BaseGeometry
    self.geometry_columns = geometry_columns
apply_function_to_detections(func, inplace=False)

summary

Parameters:

Name Type Description Default
func Callable[[GeoDataFrame], GeoDataFrame]

A function which takes in a GeoDataFrame and returns another GeoDataFrame.

required
inplace bool

Should the RegionDetections object be modified inplace, or a modified copy returned. Defaults to False.

False

Returns:

Type Description
Optional[RegionDetections]

Optional[RegionDetections]: If inplace=False, returns a modified copy. Otherwise, returns None.

Source code in tree_detection_framework/detection/region_detections.py
def apply_function_to_detections(
    self,
    func: Callable[[gpd.GeoDataFrame], gpd.GeoDataFrame],
    inplace: bool = False,
) -> Optional["RegionDetections"]:
    """_summary_

    Args:
        func (Callable[[gpd.GeoDataFrame], gpd.GeoDataFrame]):
            A function which takes in a GeoDataFrame and returns another GeoDataFrame.
        inplace (bool):
            Should the RegionDetections object be modified inplace, or a modified copy returned.
            Defaults to False.

    Returns:
        Optional[RegionDetections]:
            If inplace=False, returns a modified copy. Otherwise, returns None.
    """
    if inplace:
        # If inplace, just modify the detections directly
        self.detections = func(self.detections)
    else:
        # If not inplace, first deepcopy self to avoid any chance of modifying the original
        # .detections object
        modified_rd = copy.deepcopy(self)
        # Then apply the function
        modified_rd.detections = func(modified_rd.detections)

        return modified_rd
convert_to_bboxes()

Return a copy of the RD with the geometry of all detections replaced by the minimum axis-aligned bounding rectangle. Also, rows with an empty geometry are removed.

Returns:

Name Type Description
RegionDetections RegionDetections

An identical RD with all non-empty geometries represented as bounding boxes.

Source code in tree_detection_framework/detection/region_detections.py
def convert_to_bboxes(self) -> "RegionDetections":
    """
    Return a copy of the RD with the geometry of all detections replaced by the minimum
    axis-aligned bounding rectangle. Also, rows with an empty geometry are removed.

    Returns:
        RegionDetections: An identical RD with all non-empty geometries represented as bounding boxes.
    """
    # Get the detections
    detections_df = self.get_data_frame()
    # Get non-empty rows in the dataframe, since conversion to bounding box only works for
    # non-empty polygons
    nonempty_rows = detections_df[~detections_df.geometry.is_empty]
    # Get the bounds and convert to shapely boxes
    bounds = nonempty_rows.bounds
    boxes = shapely.box(
        xmin=bounds.minx, ymin=bounds.miny, xmax=bounds.maxx, ymax=bounds.maxy
    )
    # Update the geometry
    # TODO make sure that thisn't updating the geometry of the orignal one
    nonempty_rows.geometry = boxes
    # Create a new RegionDetections object and update the detections on it
    bbox_rd = copy.deepcopy(self)
    bbox_rd.detections = nonempty_rows
    return bbox_rd
get_CRS()

Return the CRS of the detections dataframe

Returns:

Type Description
Union[CRS, None]

Union[pyproj.CRS, None]: The CRS for the detections

Source code in tree_detection_framework/detection/region_detections.py
def get_CRS(self) -> Union[pyproj.CRS, None]:
    """Return the CRS of the detections dataframe

    Returns:
        Union[pyproj.CRS, None]: The CRS for the detections
    """
    return self.detections.crs
get_data_frame(CRS=None)

Get the detections, optionally specifying a CRS

Parameters:

Name Type Description Default
CRS Optional[CRS]

Requested CRS for the output detections. If un-set, the CRS of self.detections will be used. Defaults to None.

None

Returns:

Type Description
GeoDataFrame

gpd.GeoDataFrame: Detections in the requested CRS

Source code in tree_detection_framework/detection/region_detections.py
def get_data_frame(self, CRS: Optional[pyproj.CRS] = None) -> gpd.GeoDataFrame:
    """Get the detections, optionally specifying a CRS

    Args:
        CRS (Optional[pyproj.CRS], optional):
            Requested CRS for the output detections. If un-set, the CRS of self.detections will
            be used. Defaults to None.

    Returns:
        gpd.GeoDataFrame: Detections in the requested CRS
    """
    # If no CRS is specified, return the data as-is, using the current CRS
    if CRS is None:
        return self.detections.copy()

    # Transform the data to the requested CRS. Note that if no CRS is provided initially,
    # this will error out. This is applied to all geometry-typed columns unless explicitly
    # stated by self.geometry_columns
    detections_in_new_CRS = to_crs_multiple_geometry_columns(
        self.detections,
        crs=CRS,
        columns_to_transform=self.geometry_columns,
        inplace=False,
    )
    return detections_in_new_CRS
plot(CRS=None, plt_ax=None, plt_show=True, show_centroid=False, visualization_column=None, bounds_color=None, detection_kwargs={}, bounds_kwargs={}, raster_file=None, raster_vis_downsample=10.0)

Plot the detections and the bounds of the region

Parameters:

Name Type Description Default
CRS Optional[CRS]

What CRS to use. Defaults to None.

None
plt_ax Optional[axes]

A pyplot axes to plot on. If not provided, one will be created. Defaults to None.

None
plt_show bool

Whether to plot the result or just return it. Defaults to True.

True
show_centroid bool

Whether to plot the centroid points within the boundaries. Defaults to False.

False
visualization_column Optional[str]

Which column to visualize from the detections dataframe. Defaults to None.

None
bounds_color Optional[Union[str, array, Series]]

The color to plot the bounds. Must be accepted by the gpd.plot color argument. Defaults to None.

None
detection_kwargs dict

Additional keyword arguments to pass to the .plot method for the detections. Defaults to {}.

{}
bounds_kwargs dict

Additional keyword arguments to pass to the .plot method for the bounds. Defaults to {}.

{}
raster_file Optional[PATH_TYPE]

A path to a raster file to visualize the detections over if provided. Defaults to None.

None
raster_vis_downsample float

The raster file is downsampled by this fraction before visualization to avoid excessive memory use or plotting time. Defaults to 10.0.

10.0

Returns:

Type Description
axes

plt.axes: The axes that were plotted on

Source code in tree_detection_framework/detection/region_detections.py
def plot(
    self,
    CRS: Optional[pyproj.CRS] = None,
    plt_ax: Optional[plt.axes] = None,
    plt_show: bool = True,
    show_centroid: bool = False,
    visualization_column: Optional[str] = None,
    bounds_color: Optional[Union[str, np.array, pd.Series]] = None,
    detection_kwargs: dict = {},
    bounds_kwargs: dict = {},
    raster_file: Optional[PATH_TYPE] = None,
    raster_vis_downsample: float = 10.0,
) -> plt.axes:
    """Plot the detections and the bounds of the region

    Args:
        CRS (Optional[pyproj.CRS], optional):
            What CRS to use. Defaults to None.
        plt_ax (Optional[plt.axes], optional):
            A pyplot axes to plot on. If not provided, one will be created. Defaults to None.
        plt_show (bool, optional):
            Whether to plot the result or just return it. Defaults to True.
        show_centroid (bool, optional):
            Whether to plot the centroid points within the boundaries. Defaults to False.
        visualization_column (Optional[str], optional):
            Which column to visualize from the detections dataframe. Defaults to None.
        bounds_color (Optional[Union[str, np.array, pd.Series]], optional):
            The color to plot the bounds. Must be accepted by the gpd.plot color argument.
            Defaults to None.
        detection_kwargs (dict, optional):
            Additional keyword arguments to pass to the .plot method for the detections.
            Defaults to {}.
        bounds_kwargs (dict, optional):
            Additional keyword arguments to pass to the .plot method for the bounds.
            Defaults to {}.
        raster_file (Optional[PATH_TYPE], optional):
            A path to a raster file to visualize the detections over if provided. Defaults to None.
        raster_vis_downsample (float, optional):
            The raster file is downsampled by this fraction before visualization to avoid
            excessive memory use or plotting time. Defaults to 10.0.

    Returns:
        plt.axes: The axes that were plotted on
    """

    # Get the dataframe and the bounds
    data_frame = self.get_data_frame(CRS=CRS)
    bounds = self.get_bounds(CRS)

    # Perform plotting and return the axes
    return plot_detections(
        data_frame=data_frame,
        bounds=bounds,
        CRS=data_frame.crs,
        plt_ax=plt_ax,
        plt_show=plt_show,
        show_centroid=show_centroid,
        visualization_column=visualization_column,
        detection_kwargs=detection_kwargs,
        bounds_kwargs=bounds_kwargs,
        raster_file=raster_file,
        raster_vis_downsample=raster_vis_downsample,
        bounds_color=bounds_color,
    )
save(save_path)

Saves the information to disk

Parameters:

Name Type Description Default
save_path PATH_TYPE

Path to a geofile to save the data to. The containing folder will be created if it doesn't exist.

required
Source code in tree_detection_framework/detection/region_detections.py
def save(self, save_path: PATH_TYPE):
    """Saves the information to disk

    Args:
        save_path (PATH_TYPE):
            Path to a geofile to save the data to. The containing folder will be created if it
            doesn't exist.
    """
    # Convert to a Path object and create the containing folder if not present
    save_path = Path(save_path)
    save_path.parent.mkdir(parents=True, exist_ok=True)

    # Save the detections to a file. Note that the bounds of the prediction region are currently lost.
    self.detections.to_file(save_path)
subset_detections(detection_indices)

Return a new RegionDetections object with only the detections indicated by the indices

Parameters:

Name Type Description Default
detection_indices

Which detections to include. Can be any type that can be passed to pd.iloc.

required

Returns:

Name Type Description
RegionDetections RegionDetections

The subset of detections cooresponding to these indeices

Source code in tree_detection_framework/detection/region_detections.py
def subset_detections(self, detection_indices) -> "RegionDetections":
    """Return a new RegionDetections object with only the detections indicated by the indices

    Args:
        detection_indices:
            Which detections to include. Can be any type that can be passed to pd.iloc.

    Returns:
        RegionDetections: The subset of detections cooresponding to these indeices
    """
    # Create a deep copy of the object
    subset_rd = copy.deepcopy(self)
    # Subset the detections dataframe to the requested rows
    subset_rd.detections = subset_rd.detections.iloc[detection_indices, :]

    return subset_rd
update_geometry_column(geometry_column)

Update the geometry to another column in the dataframe that contains shapely data

Parameters:

Name Type Description Default
geometry_column str

The name of a column containing shapely data

required

Returns:

Name Type Description
RegionDetections RegionDetections

An updated RD with the geometry specified by the data in geometry_column

Source code in tree_detection_framework/detection/region_detections.py
def update_geometry_column(self, geometry_column: str) -> "RegionDetections":
    """Update the geometry to another column in the dataframe that contains shapely data

    Args:
        geometry_column (str): The name of a column containing shapely data

    Returns:
        RegionDetections: An updated RD with the geometry specified by the data in `geometry_column`
    """
    # Create a copy of the detections
    detections_df = self.get_data_frame().copy()
    # Set the geometry column to the specified one
    detections_df.geometry = detections_df[geometry_column]

    # Create a copy of the RD
    updated_geometry_rd = copy.deepcopy(self)
    # Update the detections and return
    updated_geometry_rd.detections = detections_df

    return updated_geometry_rd

RegionDetectionsSet

Source code in tree_detection_framework/detection/region_detections.py
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
class RegionDetectionsSet:
    region_detections: List[RegionDetections]

    def __init__(self, region_detections: List[RegionDetections]):
        """Create a set of detections to conveniently perform operations on all of them, for example
        merging all regions into a single dataframe with an additional column indicating which
        region each detection belongs to.

        Args:
            region_detections (List[RegionDetections]): A list of individual detections
        """
        self.region_detections = region_detections

    def apply_function_to_detections(
        self,
        func: Callable[[gpd.GeoDataFrame], gpd.GeoDataFrame],
        inplace: bool = False,
    ) -> Optional["RegionDetectionsSet"]:
        """
        See documentation for RegionDetections.apply_function_to_detections
        """
        # Convert each detection
        modified_region_detections = [
            rd.apply_function_to_detections(func=func, inplace=inplace)
            for rd in self.region_detections
        ]

        # If inplace, the individual RegionDetections objects will already have been updated and
        # None should be returned for consistency
        if inplace:
            return None

        # Create and return the new RDS
        modified_rds = RegionDetectionsSet(modified_region_detections)
        return modified_rds

    def all_regions_have_CRS(self) -> bool:
        """Check whether all sub-regions have a non-None CRS

        Returns:
            bool: Whether all sub-regions have a valid CRS.
        """
        # Get the CRS for each sub-region
        regions_CRS_values = [rd.detections.crs for rd in self.region_detections]
        # Only valid if no CRS value is None
        valid = not (None in regions_CRS_values)

        return valid

    def get_default_CRS(self, check_all_have_CRS=True) -> pyproj.CRS:
        """Find the CRS of the first sub-region to use as a default

        Args:
            check_all_have_CRS (bool, optional):
                Should an error be raised if not all regions have a CRS set.

        Returns:
            pyproj.CRS: The CRS given by the first sub-region.
        """
        if check_all_have_CRS and not self.all_regions_have_CRS():
            raise ValueError(
                "Not all regions have a CRS set and a default one was requested"
            )
        # Check that every region is geospatial
        regions_CRS_values = [rd.detections.crs for rd in self.region_detections]
        # The default is the to the CRS of the first region
        # TODO in the future it could be something else like the most common
        CRS = regions_CRS_values[0]

        return CRS

    def get_region_detections(self, index: int) -> RegionDetections:
        """Get a single region detections object by index

        Args:
            index (int): Which one to select

        Returns:
            RegionDetections: The RegionDetections object from the list of objects in the set
        """
        return self.region_detections[index]

    def merge(
        self,
        region_ID_key: Optional[str] = "region_ID",
        CRS: Optional[pyproj.CRS] = None,
    ):
        """Get the merged detections across all regions with an additional field specifying which region
        the detection came from.

        Args:
            region_ID_key (Optional[str], optional):
                Create this column in the output dataframe identifying which region that data came
                from using a zero-indexed integer. Defaults to "region_ID".
            CRS (Optional[pyproj.CRS], optional):
                Requested CRS for merged detections. If un-set, the CRS of the first region will
                be used. Defaults to None.

        Returns:
            gpd.GeoDataFrame: Detections in the requested CRS
        """

        # Get the detections from each region detection object as geodataframes
        detection_geodataframes = [
            rd.get_data_frame(CRS=CRS) for rd in self.region_detections
        ]

        # Add a column to each geodataframe identifying which region detection object it came from
        # Note that dataframes in the original list are updated
        # TODO consider a more sophisticated ID
        for ID, gdf in enumerate(detection_geodataframes):
            gdf[region_ID_key] = ID

        # Concatenate the geodataframes together
        concatenated_geodataframes = pd.concat(
            detection_geodataframes, ignore_index=True
        )

        # Add a globally unique 5-digit ID column
        concatenated_geodataframes["unique_ID"] = concatenated_geodataframes.index.map(
            lambda i: f"{i:05d}"
        )

        ## Merge_bounds
        # Get the merged bounds
        merged_bounds = self.get_bounds(CRS=CRS)
        # Convert to a single shapely object
        merged_bounds_shapely = merged_bounds.geometry[0]

        # Use the geometry column of the concatenated dataframes
        merged_region_detections = RegionDetections(
            detection_geometries="geometry",
            data=concatenated_geodataframes,
            CRS=CRS,
            geospatial_prediction_bounds=merged_bounds_shapely,
        )

        return merged_region_detections

    def get_data_frame(
        self,
        CRS: Optional[pyproj.CRS] = None,
        merge: bool = False,
        region_ID_key: str = "region_ID",
    ) -> gpd.GeoDataFrame | List[gpd.GeoDataFrame]:
        """Get the detections, optionally specifying a CRS

        Args:
            CRS (Optional[pyproj.CRS], optional):
                Requested CRS for the output detections. If un-set, the CRS of self.detections will
                be used. Defaults to None.
            merge (bool, optional):
                If true, return one dataframe. Else, return a list of individual dataframes.
            region_ID_key (str, optional):
                Use this column to identify which region each detection came from. Defaults to
                "region_ID"

        Returns:
            gpd.GeoDataFrame | List[gpd.GeoDataFrame]:
                If merge=True, then one dataframe with an addtional column specifying which region each
                detection came from. If merge=False, then a list of dataframes for each region.
        """
        if merge:
            # Merge all of the detections into one RegionDetection
            merged_detections = self.merge(region_ID_key=region_ID_key, CRS=CRS)
            # get the dataframe. It is already in the requested CRS in the current implementation.
            data_frame = merged_detections.get_data_frame()
            return data_frame

        # Get a list of dataframes from each region
        list_of_region_data_frames = [
            rd.get_data_frame(CRS=CRS) for rd in self.region_detections
        ]
        return list_of_region_data_frames

    def convert_to_bboxes(self) -> "RegionDetectionsSet":
        """Convert all the RegionDetections to bounding box representations

        Returns:
            RegionDetectionsSet:
                A new RDS where each RD has all empty geometries dropped and all remaning ones
                represented by an axis-aligned rectangle.
        """
        # Convert each detection
        converted_detections = [rd.convert_to_bboxes() for rd in self.region_detections]
        # Return the new RDS
        bboxes_rds = RegionDetectionsSet(converted_detections)
        return bboxes_rds

    def update_geometry_column(self, geometry_column: str) -> "RegionDetectionsSet":
        """
        Update the geometry to another column in the dataframe that contains shapely data for each RD

        Args:
            geometry_column (str): The name of a column containing shapely data

        Returns:
            RegionDetectionsSet: An updated RDS with the geometry specified by the data in `geometry_column`
        """
        # Convert each detection
        converted_detections = [
            rd.update_geometry_column(geometry_column=geometry_column)
            for rd in self.region_detections
        ]
        # Return the new RDS
        updated_geometry_rds = RegionDetectionsSet(converted_detections)
        return updated_geometry_rds

    def get_bounds(
        self, CRS: Optional[pyproj.CRS] = None, union_bounds: bool = True
    ) -> gpd.GeoSeries:
        """Get the bounds corresponding to the sub-regions.

        Args:
            CRS (Optional[pyproj.CRS], optional):
                The CRS to return the bounds in. If not set, it will be the bounds of the first
                region. Defaults to None.
            union_bounds (bool, optional):
                Whether to return the spatial union of all bounds or a series of per-region bounds.
                Defaults to True.

        Returns:
            gpd.GeoSeries: Either a one-length series of merged bounds if merge=True or a series
            of bounds per region.
        """

        region_bounds = [rd.get_bounds(CRS=CRS) for rd in self.region_detections]
        # Create a geodataframe out of these region bounds
        all_region_bounds = gpd.GeoSeries(pd.concat(region_bounds), crs=CRS)

        # If the union is not requested, return the individual bounds
        if not union_bounds:
            return all_region_bounds

        # Compute the union of all bounds
        merged_bounds = gpd.GeoSeries([all_region_bounds.geometry.union_all()], crs=CRS)

        return merged_bounds

    def disjoint_bounds(self) -> bool:
        """Determine whether the bounds of the sub-regions are disjoint

        Returns:
            bool: Are they disjoint
        """
        # Get the bounds for each individual region
        bounds = self.get_bounds(union_bounds=False)
        # Get the union of all bounds
        union_bounds = bounds.union_all()

        # Find the sum of areas for each region
        sum_individual_areas = bounds.area.sum()
        # And the area of the union
        union_area = union_bounds.area

        # If the two areas are the same (down to numeric errors) then there are no overlaps
        disjoint = np.allclose(sum_individual_areas, union_area)

        return disjoint

    def save(
        self,
        save_path: PATH_TYPE,
        CRS: Optional[pyproj.CRS] = None,
        region_ID_key: Optional[str] = "region_ID",
    ):
        """
        Save the data to a geospatial file by calling get_data_frame with merge=True and then saving
        to the specified file. The containing folder is created if it doesn't exist.
        NOTE: Use `save_tiled()` if you want to preserve the tile structure and bounds of each individual region.

        Args:
            save_path (PATH_TYPE):
               File to save the data to. The containing folder will be created if it does not exist.
            CRS (Optional[pyproj.CRS], optional):
                See get_data_frame.
            region_ID_key (Optional[str], optional):
                See get_data_frame.
        """
        # Get the concatenated dataframes
        concatenated_geodataframes = self.get_data_frame(
            CRS=CRS, region_ID_key=region_ID_key, merge=True
        )

        # Ensure that the folder to save them to exists
        save_path = Path(save_path)
        save_path.parent.mkdir(exist_ok=True, parents=True)

        # Save the data to the geofile
        concatenated_geodataframes.to_file(save_path)

    def save_tiled(self, save_path: PATH_TYPE, CRS: Optional[pyproj.CRS] = None):
        """Save to a GPKG file with a single detections layer (all tiles) and a bounds layer.

        Layout:
          - "detections": one layer holding all detections with a "region_ID" column
          - "bounds": one layer holding all tile bounds with a "region_ID" column

        This preserves tile structure (including empty tiles).
        Use from_tiled_file to reconstruct the RegionDetectionsSet exactly.

        Args:
            save_path (PATH_TYPE):
                Path to a .gpkg file.
            CRS (Optional[pyproj.CRS], optional):
                CRS to reproject all layers into before saving. Defaults to None (no reprojection).
        """
        save_path = Path(save_path)
        save_path.parent.mkdir(exist_ok=True, parents=True)

        detection_frames = []
        bounds_records = []
        detections_crs = CRS  # inferred from first non-empty tile if not provided

        for i, rd in enumerate(self.region_detections):
            detections = rd.get_data_frame(CRS=CRS)
            if detections_crs is None and len(detections) > 0:
                detections_crs = detections.crs
            if len(detections) > 0:
                detections["region_ID"] = i  # tag each row with its tile index
                detection_frames.append(detections)
            # always record bounds, even for empty tiles
            bounds_geom = rd.get_bounds(CRS=CRS).iloc[0]
            bounds_records.append({"region_ID": i, "geometry": bounds_geom})

        # Concatenate all tiles into one layer; write an empty frame if there are no detections
        if detection_frames:
            all_detections = gpd.GeoDataFrame(
                pd.concat(detection_frames, ignore_index=True), crs=detections_crs
            )
        else:
            all_detections = gpd.GeoDataFrame(
                {"region_ID": pd.Series([], dtype=int)},
                geometry=gpd.GeoSeries([], crs=detections_crs),
                crs=detections_crs,
            )
        all_detections.to_file(save_path, layer="detections", driver="GPKG", mode="w")

        # Collect all bounds into a single layer
        bounds_crs = (
            CRS if CRS is not None else self.get_default_CRS(check_all_have_CRS=False)
        )
        bounds_gdf = gpd.GeoDataFrame(bounds_records, crs=bounds_crs)
        bounds_gdf.to_file(save_path, layer="bounds", driver="GPKG", mode="a")

    @classmethod
    def from_tiled_file(cls, save_path: PATH_TYPE) -> "RegionDetectionsSet":
        """Load a RegionDetectionsSet from a .gpkg file created by save_tiled.

        Args:
            save_path (PATH_TYPE): Path to a .gpkg file written by save_tiled.

        Returns:
            RegionDetectionsSet: The reconstructed set with one RegionDetections per tile.
        """
        save_path = Path(save_path)

        bounds_gdf = gpd.read_file(save_path, layer="bounds").set_index("region_ID")
        n_tiles = len(bounds_gdf)
        crs = bounds_gdf.crs

        # Split the flat detections layer back into per-tile groups
        # drop region_ID since it's storage metadata
        all_detections = gpd.read_file(save_path, layer="detections")
        if len(all_detections) > 0:
            crs = all_detections.crs
            detections_by_tile = {
                region_id: group.drop(columns=["region_ID"]).reset_index(drop=True)
                for region_id, group in all_detections.groupby("region_ID")
            }
            # Build an empty GeoDataFrame with the same schema for tiles with no detections
            schema_cols = all_detections.drop(columns=["region_ID"])
            empty_tile_template = gpd.GeoDataFrame(
                {
                    col: pd.Series(dtype=schema_cols[col].dtype)
                    for col in schema_cols.columns
                },
                geometry="geometry",
                crs=crs,
            )
        else:
            detections_by_tile = {}
            empty_tile_template = gpd.GeoDataFrame(geometry=gpd.GeoSeries([], crs=crs))

        region_detections = []
        for i in range(n_tiles):
            bounds_geom = (
                bounds_gdf.loc[i, "geometry"] if i in bounds_gdf.index else None
            )

            # Get the detections for this tile if they exist
            if i in detections_by_tile:
                tile_detections = detections_by_tile[i]
            else:
                # tile was empty - reconstruct with an empty GeoDataFrame matching the file schema
                tile_detections = empty_tile_template.copy()

            # Create a RegionDetections object for this tile
            rd = RegionDetections(
                detection_geometries="geometry",
                data=tile_detections,
                CRS=crs,
                geospatial_prediction_bounds=bounds_geom,
            )
            region_detections.append(rd)

        # Create and return the RegionDetectionsSet
        return cls(region_detections)

    def plot(
        self,
        CRS: Optional[pyproj.CRS] = None,
        plt_ax: Optional[plt.axes] = None,
        plt_show: bool = True,
        visualization_column: Optional[str] = None,
        bounds_color: Optional[Union[str, np.array, pd.Series]] = None,
        detection_kwargs: dict = {},
        bounds_kwargs: dict = {},
        raster_file: Optional[PATH_TYPE] = None,
        raster_vis_downsample: float = 10.0,
    ) -> plt.axes:
        """Plot each of the region detections using their .plot method

        Args:
            CRS (Optional[pyproj.CRS], optional):
                The CRS to use for plotting all regions. If unset, the default one for this object
                will be selected. Defaults to None.
            plt_ax (Optional[plt.axes], optional):
                The axes to plot on. Will be created if not provided. Defaults to None.
            plt_show (bool, optional):
                See RegionDetections.plot. Defaults to True.
            visualization_column (Optional[str], optional):
                See regiondetections.plot. Defaults to None.
            bounds_color (Optional[Union[str, np.array, pd.Series]], optional):
                See regiondetections.plot. Defaults to None.
            detection_kwargs (dict, optional):
                See regiondetections.plot. Defaults to {}.
            bounds_kwargs (dict, optional):
                See regiondetections.plot. Defaults to {}.
            raster_file (Optional[PATH_TYPE], optional):
                See regiondetections.plot. Defaults to None.
            raster_vis_downsample (float, optional):
                See regiondetections.plot. Defaults to 10.0.

        Returns:
            plt.axes: The axes that have been plotted on.
        """
        # Extract the bounds for each of the sub-regions
        bounds = self.get_bounds(CRS=CRS, union_bounds=False)
        data_frame = self.get_data_frame(CRS=CRS, merge=True)

        # Perform plotting and return the axes
        return plot_detections(
            data_frame=data_frame,
            bounds=bounds,
            CRS=data_frame.crs,
            plt_ax=plt_ax,
            plt_show=plt_show,
            visualization_column=visualization_column,
            bounds_color=bounds_color,
            detection_kwargs=detection_kwargs,
            bounds_kwargs=bounds_kwargs,
            raster_file=raster_file,
            raster_vis_downsample=raster_vis_downsample,
        )
Methods:
__init__(region_detections)

Create a set of detections to conveniently perform operations on all of them, for example merging all regions into a single dataframe with an additional column indicating which region each detection belongs to.

Parameters:

Name Type Description Default
region_detections List[RegionDetections]

A list of individual detections

required
Source code in tree_detection_framework/detection/region_detections.py
def __init__(self, region_detections: List[RegionDetections]):
    """Create a set of detections to conveniently perform operations on all of them, for example
    merging all regions into a single dataframe with an additional column indicating which
    region each detection belongs to.

    Args:
        region_detections (List[RegionDetections]): A list of individual detections
    """
    self.region_detections = region_detections
all_regions_have_CRS()

Check whether all sub-regions have a non-None CRS

Returns:

Name Type Description
bool bool

Whether all sub-regions have a valid CRS.

Source code in tree_detection_framework/detection/region_detections.py
def all_regions_have_CRS(self) -> bool:
    """Check whether all sub-regions have a non-None CRS

    Returns:
        bool: Whether all sub-regions have a valid CRS.
    """
    # Get the CRS for each sub-region
    regions_CRS_values = [rd.detections.crs for rd in self.region_detections]
    # Only valid if no CRS value is None
    valid = not (None in regions_CRS_values)

    return valid
apply_function_to_detections(func, inplace=False)

See documentation for RegionDetections.apply_function_to_detections

Source code in tree_detection_framework/detection/region_detections.py
def apply_function_to_detections(
    self,
    func: Callable[[gpd.GeoDataFrame], gpd.GeoDataFrame],
    inplace: bool = False,
) -> Optional["RegionDetectionsSet"]:
    """
    See documentation for RegionDetections.apply_function_to_detections
    """
    # Convert each detection
    modified_region_detections = [
        rd.apply_function_to_detections(func=func, inplace=inplace)
        for rd in self.region_detections
    ]

    # If inplace, the individual RegionDetections objects will already have been updated and
    # None should be returned for consistency
    if inplace:
        return None

    # Create and return the new RDS
    modified_rds = RegionDetectionsSet(modified_region_detections)
    return modified_rds
convert_to_bboxes()

Convert all the RegionDetections to bounding box representations

Returns:

Name Type Description
RegionDetectionsSet RegionDetectionsSet

A new RDS where each RD has all empty geometries dropped and all remaning ones represented by an axis-aligned rectangle.

Source code in tree_detection_framework/detection/region_detections.py
def convert_to_bboxes(self) -> "RegionDetectionsSet":
    """Convert all the RegionDetections to bounding box representations

    Returns:
        RegionDetectionsSet:
            A new RDS where each RD has all empty geometries dropped and all remaning ones
            represented by an axis-aligned rectangle.
    """
    # Convert each detection
    converted_detections = [rd.convert_to_bboxes() for rd in self.region_detections]
    # Return the new RDS
    bboxes_rds = RegionDetectionsSet(converted_detections)
    return bboxes_rds
disjoint_bounds()

Determine whether the bounds of the sub-regions are disjoint

Returns:

Name Type Description
bool bool

Are they disjoint

Source code in tree_detection_framework/detection/region_detections.py
def disjoint_bounds(self) -> bool:
    """Determine whether the bounds of the sub-regions are disjoint

    Returns:
        bool: Are they disjoint
    """
    # Get the bounds for each individual region
    bounds = self.get_bounds(union_bounds=False)
    # Get the union of all bounds
    union_bounds = bounds.union_all()

    # Find the sum of areas for each region
    sum_individual_areas = bounds.area.sum()
    # And the area of the union
    union_area = union_bounds.area

    # If the two areas are the same (down to numeric errors) then there are no overlaps
    disjoint = np.allclose(sum_individual_areas, union_area)

    return disjoint
from_tiled_file(save_path) classmethod

Load a RegionDetectionsSet from a .gpkg file created by save_tiled.

Parameters:

Name Type Description Default
save_path PATH_TYPE

Path to a .gpkg file written by save_tiled.

required

Returns:

Name Type Description
RegionDetectionsSet RegionDetectionsSet

The reconstructed set with one RegionDetections per tile.

Source code in tree_detection_framework/detection/region_detections.py
@classmethod
def from_tiled_file(cls, save_path: PATH_TYPE) -> "RegionDetectionsSet":
    """Load a RegionDetectionsSet from a .gpkg file created by save_tiled.

    Args:
        save_path (PATH_TYPE): Path to a .gpkg file written by save_tiled.

    Returns:
        RegionDetectionsSet: The reconstructed set with one RegionDetections per tile.
    """
    save_path = Path(save_path)

    bounds_gdf = gpd.read_file(save_path, layer="bounds").set_index("region_ID")
    n_tiles = len(bounds_gdf)
    crs = bounds_gdf.crs

    # Split the flat detections layer back into per-tile groups
    # drop region_ID since it's storage metadata
    all_detections = gpd.read_file(save_path, layer="detections")
    if len(all_detections) > 0:
        crs = all_detections.crs
        detections_by_tile = {
            region_id: group.drop(columns=["region_ID"]).reset_index(drop=True)
            for region_id, group in all_detections.groupby("region_ID")
        }
        # Build an empty GeoDataFrame with the same schema for tiles with no detections
        schema_cols = all_detections.drop(columns=["region_ID"])
        empty_tile_template = gpd.GeoDataFrame(
            {
                col: pd.Series(dtype=schema_cols[col].dtype)
                for col in schema_cols.columns
            },
            geometry="geometry",
            crs=crs,
        )
    else:
        detections_by_tile = {}
        empty_tile_template = gpd.GeoDataFrame(geometry=gpd.GeoSeries([], crs=crs))

    region_detections = []
    for i in range(n_tiles):
        bounds_geom = (
            bounds_gdf.loc[i, "geometry"] if i in bounds_gdf.index else None
        )

        # Get the detections for this tile if they exist
        if i in detections_by_tile:
            tile_detections = detections_by_tile[i]
        else:
            # tile was empty - reconstruct with an empty GeoDataFrame matching the file schema
            tile_detections = empty_tile_template.copy()

        # Create a RegionDetections object for this tile
        rd = RegionDetections(
            detection_geometries="geometry",
            data=tile_detections,
            CRS=crs,
            geospatial_prediction_bounds=bounds_geom,
        )
        region_detections.append(rd)

    # Create and return the RegionDetectionsSet
    return cls(region_detections)
get_bounds(CRS=None, union_bounds=True)

Get the bounds corresponding to the sub-regions.

Parameters:

Name Type Description Default
CRS Optional[CRS]

The CRS to return the bounds in. If not set, it will be the bounds of the first region. Defaults to None.

None
union_bounds bool

Whether to return the spatial union of all bounds or a series of per-region bounds. Defaults to True.

True

Returns:

Type Description
GeoSeries

gpd.GeoSeries: Either a one-length series of merged bounds if merge=True or a series

GeoSeries

of bounds per region.

Source code in tree_detection_framework/detection/region_detections.py
def get_bounds(
    self, CRS: Optional[pyproj.CRS] = None, union_bounds: bool = True
) -> gpd.GeoSeries:
    """Get the bounds corresponding to the sub-regions.

    Args:
        CRS (Optional[pyproj.CRS], optional):
            The CRS to return the bounds in. If not set, it will be the bounds of the first
            region. Defaults to None.
        union_bounds (bool, optional):
            Whether to return the spatial union of all bounds or a series of per-region bounds.
            Defaults to True.

    Returns:
        gpd.GeoSeries: Either a one-length series of merged bounds if merge=True or a series
        of bounds per region.
    """

    region_bounds = [rd.get_bounds(CRS=CRS) for rd in self.region_detections]
    # Create a geodataframe out of these region bounds
    all_region_bounds = gpd.GeoSeries(pd.concat(region_bounds), crs=CRS)

    # If the union is not requested, return the individual bounds
    if not union_bounds:
        return all_region_bounds

    # Compute the union of all bounds
    merged_bounds = gpd.GeoSeries([all_region_bounds.geometry.union_all()], crs=CRS)

    return merged_bounds
get_data_frame(CRS=None, merge=False, region_ID_key='region_ID')

Get the detections, optionally specifying a CRS

Parameters:

Name Type Description Default
CRS Optional[CRS]

Requested CRS for the output detections. If un-set, the CRS of self.detections will be used. Defaults to None.

None
merge bool

If true, return one dataframe. Else, return a list of individual dataframes.

False
region_ID_key str

Use this column to identify which region each detection came from. Defaults to "region_ID"

'region_ID'

Returns:

Type Description
GeoDataFrame | List[GeoDataFrame]

gpd.GeoDataFrame | List[gpd.GeoDataFrame]: If merge=True, then one dataframe with an addtional column specifying which region each detection came from. If merge=False, then a list of dataframes for each region.

Source code in tree_detection_framework/detection/region_detections.py
def get_data_frame(
    self,
    CRS: Optional[pyproj.CRS] = None,
    merge: bool = False,
    region_ID_key: str = "region_ID",
) -> gpd.GeoDataFrame | List[gpd.GeoDataFrame]:
    """Get the detections, optionally specifying a CRS

    Args:
        CRS (Optional[pyproj.CRS], optional):
            Requested CRS for the output detections. If un-set, the CRS of self.detections will
            be used. Defaults to None.
        merge (bool, optional):
            If true, return one dataframe. Else, return a list of individual dataframes.
        region_ID_key (str, optional):
            Use this column to identify which region each detection came from. Defaults to
            "region_ID"

    Returns:
        gpd.GeoDataFrame | List[gpd.GeoDataFrame]:
            If merge=True, then one dataframe with an addtional column specifying which region each
            detection came from. If merge=False, then a list of dataframes for each region.
    """
    if merge:
        # Merge all of the detections into one RegionDetection
        merged_detections = self.merge(region_ID_key=region_ID_key, CRS=CRS)
        # get the dataframe. It is already in the requested CRS in the current implementation.
        data_frame = merged_detections.get_data_frame()
        return data_frame

    # Get a list of dataframes from each region
    list_of_region_data_frames = [
        rd.get_data_frame(CRS=CRS) for rd in self.region_detections
    ]
    return list_of_region_data_frames
get_default_CRS(check_all_have_CRS=True)

Find the CRS of the first sub-region to use as a default

Parameters:

Name Type Description Default
check_all_have_CRS bool

Should an error be raised if not all regions have a CRS set.

True

Returns:

Type Description
CRS

pyproj.CRS: The CRS given by the first sub-region.

Source code in tree_detection_framework/detection/region_detections.py
def get_default_CRS(self, check_all_have_CRS=True) -> pyproj.CRS:
    """Find the CRS of the first sub-region to use as a default

    Args:
        check_all_have_CRS (bool, optional):
            Should an error be raised if not all regions have a CRS set.

    Returns:
        pyproj.CRS: The CRS given by the first sub-region.
    """
    if check_all_have_CRS and not self.all_regions_have_CRS():
        raise ValueError(
            "Not all regions have a CRS set and a default one was requested"
        )
    # Check that every region is geospatial
    regions_CRS_values = [rd.detections.crs for rd in self.region_detections]
    # The default is the to the CRS of the first region
    # TODO in the future it could be something else like the most common
    CRS = regions_CRS_values[0]

    return CRS
get_region_detections(index)

Get a single region detections object by index

Parameters:

Name Type Description Default
index int

Which one to select

required

Returns:

Name Type Description
RegionDetections RegionDetections

The RegionDetections object from the list of objects in the set

Source code in tree_detection_framework/detection/region_detections.py
def get_region_detections(self, index: int) -> RegionDetections:
    """Get a single region detections object by index

    Args:
        index (int): Which one to select

    Returns:
        RegionDetections: The RegionDetections object from the list of objects in the set
    """
    return self.region_detections[index]
merge(region_ID_key='region_ID', CRS=None)

Get the merged detections across all regions with an additional field specifying which region the detection came from.

Parameters:

Name Type Description Default
region_ID_key Optional[str]

Create this column in the output dataframe identifying which region that data came from using a zero-indexed integer. Defaults to "region_ID".

'region_ID'
CRS Optional[CRS]

Requested CRS for merged detections. If un-set, the CRS of the first region will be used. Defaults to None.

None

Returns:

Type Description

gpd.GeoDataFrame: Detections in the requested CRS

Source code in tree_detection_framework/detection/region_detections.py
def merge(
    self,
    region_ID_key: Optional[str] = "region_ID",
    CRS: Optional[pyproj.CRS] = None,
):
    """Get the merged detections across all regions with an additional field specifying which region
    the detection came from.

    Args:
        region_ID_key (Optional[str], optional):
            Create this column in the output dataframe identifying which region that data came
            from using a zero-indexed integer. Defaults to "region_ID".
        CRS (Optional[pyproj.CRS], optional):
            Requested CRS for merged detections. If un-set, the CRS of the first region will
            be used. Defaults to None.

    Returns:
        gpd.GeoDataFrame: Detections in the requested CRS
    """

    # Get the detections from each region detection object as geodataframes
    detection_geodataframes = [
        rd.get_data_frame(CRS=CRS) for rd in self.region_detections
    ]

    # Add a column to each geodataframe identifying which region detection object it came from
    # Note that dataframes in the original list are updated
    # TODO consider a more sophisticated ID
    for ID, gdf in enumerate(detection_geodataframes):
        gdf[region_ID_key] = ID

    # Concatenate the geodataframes together
    concatenated_geodataframes = pd.concat(
        detection_geodataframes, ignore_index=True
    )

    # Add a globally unique 5-digit ID column
    concatenated_geodataframes["unique_ID"] = concatenated_geodataframes.index.map(
        lambda i: f"{i:05d}"
    )

    ## Merge_bounds
    # Get the merged bounds
    merged_bounds = self.get_bounds(CRS=CRS)
    # Convert to a single shapely object
    merged_bounds_shapely = merged_bounds.geometry[0]

    # Use the geometry column of the concatenated dataframes
    merged_region_detections = RegionDetections(
        detection_geometries="geometry",
        data=concatenated_geodataframes,
        CRS=CRS,
        geospatial_prediction_bounds=merged_bounds_shapely,
    )

    return merged_region_detections
plot(CRS=None, plt_ax=None, plt_show=True, visualization_column=None, bounds_color=None, detection_kwargs={}, bounds_kwargs={}, raster_file=None, raster_vis_downsample=10.0)

Plot each of the region detections using their .plot method

Parameters:

Name Type Description Default
CRS Optional[CRS]

The CRS to use for plotting all regions. If unset, the default one for this object will be selected. Defaults to None.

None
plt_ax Optional[axes]

The axes to plot on. Will be created if not provided. Defaults to None.

None
plt_show bool

See RegionDetections.plot. Defaults to True.

True
visualization_column Optional[str]

See regiondetections.plot. Defaults to None.

None
bounds_color Optional[Union[str, array, Series]]

See regiondetections.plot. Defaults to None.

None
detection_kwargs dict

See regiondetections.plot. Defaults to {}.

{}
bounds_kwargs dict

See regiondetections.plot. Defaults to {}.

{}
raster_file Optional[PATH_TYPE]

See regiondetections.plot. Defaults to None.

None
raster_vis_downsample float

See regiondetections.plot. Defaults to 10.0.

10.0

Returns:

Type Description
axes

plt.axes: The axes that have been plotted on.

Source code in tree_detection_framework/detection/region_detections.py
def plot(
    self,
    CRS: Optional[pyproj.CRS] = None,
    plt_ax: Optional[plt.axes] = None,
    plt_show: bool = True,
    visualization_column: Optional[str] = None,
    bounds_color: Optional[Union[str, np.array, pd.Series]] = None,
    detection_kwargs: dict = {},
    bounds_kwargs: dict = {},
    raster_file: Optional[PATH_TYPE] = None,
    raster_vis_downsample: float = 10.0,
) -> plt.axes:
    """Plot each of the region detections using their .plot method

    Args:
        CRS (Optional[pyproj.CRS], optional):
            The CRS to use for plotting all regions. If unset, the default one for this object
            will be selected. Defaults to None.
        plt_ax (Optional[plt.axes], optional):
            The axes to plot on. Will be created if not provided. Defaults to None.
        plt_show (bool, optional):
            See RegionDetections.plot. Defaults to True.
        visualization_column (Optional[str], optional):
            See regiondetections.plot. Defaults to None.
        bounds_color (Optional[Union[str, np.array, pd.Series]], optional):
            See regiondetections.plot. Defaults to None.
        detection_kwargs (dict, optional):
            See regiondetections.plot. Defaults to {}.
        bounds_kwargs (dict, optional):
            See regiondetections.plot. Defaults to {}.
        raster_file (Optional[PATH_TYPE], optional):
            See regiondetections.plot. Defaults to None.
        raster_vis_downsample (float, optional):
            See regiondetections.plot. Defaults to 10.0.

    Returns:
        plt.axes: The axes that have been plotted on.
    """
    # Extract the bounds for each of the sub-regions
    bounds = self.get_bounds(CRS=CRS, union_bounds=False)
    data_frame = self.get_data_frame(CRS=CRS, merge=True)

    # Perform plotting and return the axes
    return plot_detections(
        data_frame=data_frame,
        bounds=bounds,
        CRS=data_frame.crs,
        plt_ax=plt_ax,
        plt_show=plt_show,
        visualization_column=visualization_column,
        bounds_color=bounds_color,
        detection_kwargs=detection_kwargs,
        bounds_kwargs=bounds_kwargs,
        raster_file=raster_file,
        raster_vis_downsample=raster_vis_downsample,
    )
save(save_path, CRS=None, region_ID_key='region_ID')

Save the data to a geospatial file by calling get_data_frame with merge=True and then saving to the specified file. The containing folder is created if it doesn't exist. NOTE: Use save_tiled() if you want to preserve the tile structure and bounds of each individual region.

Parameters:

Name Type Description Default
save_path PATH_TYPE

File to save the data to. The containing folder will be created if it does not exist.

required
CRS Optional[CRS]

See get_data_frame.

None
region_ID_key Optional[str]

See get_data_frame.

'region_ID'
Source code in tree_detection_framework/detection/region_detections.py
def save(
    self,
    save_path: PATH_TYPE,
    CRS: Optional[pyproj.CRS] = None,
    region_ID_key: Optional[str] = "region_ID",
):
    """
    Save the data to a geospatial file by calling get_data_frame with merge=True and then saving
    to the specified file. The containing folder is created if it doesn't exist.
    NOTE: Use `save_tiled()` if you want to preserve the tile structure and bounds of each individual region.

    Args:
        save_path (PATH_TYPE):
           File to save the data to. The containing folder will be created if it does not exist.
        CRS (Optional[pyproj.CRS], optional):
            See get_data_frame.
        region_ID_key (Optional[str], optional):
            See get_data_frame.
    """
    # Get the concatenated dataframes
    concatenated_geodataframes = self.get_data_frame(
        CRS=CRS, region_ID_key=region_ID_key, merge=True
    )

    # Ensure that the folder to save them to exists
    save_path = Path(save_path)
    save_path.parent.mkdir(exist_ok=True, parents=True)

    # Save the data to the geofile
    concatenated_geodataframes.to_file(save_path)
save_tiled(save_path, CRS=None)

Save to a GPKG file with a single detections layer (all tiles) and a bounds layer.

Layout
  • "detections": one layer holding all detections with a "region_ID" column
  • "bounds": one layer holding all tile bounds with a "region_ID" column

This preserves tile structure (including empty tiles). Use from_tiled_file to reconstruct the RegionDetectionsSet exactly.

Parameters:

Name Type Description Default
save_path PATH_TYPE

Path to a .gpkg file.

required
CRS Optional[CRS]

CRS to reproject all layers into before saving. Defaults to None (no reprojection).

None
Source code in tree_detection_framework/detection/region_detections.py
def save_tiled(self, save_path: PATH_TYPE, CRS: Optional[pyproj.CRS] = None):
    """Save to a GPKG file with a single detections layer (all tiles) and a bounds layer.

    Layout:
      - "detections": one layer holding all detections with a "region_ID" column
      - "bounds": one layer holding all tile bounds with a "region_ID" column

    This preserves tile structure (including empty tiles).
    Use from_tiled_file to reconstruct the RegionDetectionsSet exactly.

    Args:
        save_path (PATH_TYPE):
            Path to a .gpkg file.
        CRS (Optional[pyproj.CRS], optional):
            CRS to reproject all layers into before saving. Defaults to None (no reprojection).
    """
    save_path = Path(save_path)
    save_path.parent.mkdir(exist_ok=True, parents=True)

    detection_frames = []
    bounds_records = []
    detections_crs = CRS  # inferred from first non-empty tile if not provided

    for i, rd in enumerate(self.region_detections):
        detections = rd.get_data_frame(CRS=CRS)
        if detections_crs is None and len(detections) > 0:
            detections_crs = detections.crs
        if len(detections) > 0:
            detections["region_ID"] = i  # tag each row with its tile index
            detection_frames.append(detections)
        # always record bounds, even for empty tiles
        bounds_geom = rd.get_bounds(CRS=CRS).iloc[0]
        bounds_records.append({"region_ID": i, "geometry": bounds_geom})

    # Concatenate all tiles into one layer; write an empty frame if there are no detections
    if detection_frames:
        all_detections = gpd.GeoDataFrame(
            pd.concat(detection_frames, ignore_index=True), crs=detections_crs
        )
    else:
        all_detections = gpd.GeoDataFrame(
            {"region_ID": pd.Series([], dtype=int)},
            geometry=gpd.GeoSeries([], crs=detections_crs),
            crs=detections_crs,
        )
    all_detections.to_file(save_path, layer="detections", driver="GPKG", mode="w")

    # Collect all bounds into a single layer
    bounds_crs = (
        CRS if CRS is not None else self.get_default_CRS(check_all_have_CRS=False)
    )
    bounds_gdf = gpd.GeoDataFrame(bounds_records, crs=bounds_crs)
    bounds_gdf.to_file(save_path, layer="bounds", driver="GPKG", mode="a")
update_geometry_column(geometry_column)

Update the geometry to another column in the dataframe that contains shapely data for each RD

Parameters:

Name Type Description Default
geometry_column str

The name of a column containing shapely data

required

Returns:

Name Type Description
RegionDetectionsSet RegionDetectionsSet

An updated RDS with the geometry specified by the data in geometry_column

Source code in tree_detection_framework/detection/region_detections.py
def update_geometry_column(self, geometry_column: str) -> "RegionDetectionsSet":
    """
    Update the geometry to another column in the dataframe that contains shapely data for each RD

    Args:
        geometry_column (str): The name of a column containing shapely data

    Returns:
        RegionDetectionsSet: An updated RDS with the geometry specified by the data in `geometry_column`
    """
    # Convert each detection
    converted_detections = [
        rd.update_geometry_column(geometry_column=geometry_column)
        for rd in self.region_detections
    ]
    # Return the new RDS
    updated_geometry_rds = RegionDetectionsSet(converted_detections)
    return updated_geometry_rds

Functions:

plot_detections(data_frame, bounds, CRS=None, plt_ax=None, plt_show=True, show_centroid=False, visualization_column=None, bounds_color=None, detection_kwargs={}, bounds_kwargs={}, raster_file=None, raster_vis_downsample=10.0)

Plot the detections and the bounds of the region

Parameters:

Name Type Description Default
data_frame GeoDataFrame

(gpd.GeoDataFrame): The data representing the detections

required
bounds GeoSeries

(gpd.GeoSeries): The spatial bounds of the predicted region

required
CRS Optional[CRS]

What CRS to use. Defaults to None.

None
plt_ax Optional[axes]

A pyplot axes to plot on. If not provided, one will be created. Defaults to None.

None
plt_show bool

Whether to plot the result or just return it. Defaults to True.

True
show_centroid bool

Whether to plot the centroid points within the boundaries. Defaults to False.

False
visualization_column Optional[str]

Which column to visualize from the detections dataframe. Defaults to None.

None
bounds_color Optional[Union[str, array, Series]]

The color to plot the bounds. Must be accepted by the gpd.plot color argument. Defaults to None.

None
detection_kwargs dict

Additional keyword arguments to pass to the .plot method for the detections. Defaults to {}.

{}
bounds_kwargs dict

Additional keyword arguments to pass to the .plot method for the bounds. Defaults to {}.

{}
raster_file Optional[PATH_TYPE]

A path to a raster file to visualize the detections over if provided. Defaults to None.

None
raster_vis_downsample float

The raster file is downsampled by this fraction before visualization to avoid excessive memory use or plotting time. Defaults to 10.0.

10.0

Returns:

Type Description
axes

plt.axes: The axes that were plotted on

Source code in tree_detection_framework/detection/region_detections.py
def plot_detections(
    data_frame: gpd.GeoDataFrame,
    bounds: gpd.GeoSeries,
    CRS: Optional[pyproj.CRS] = None,
    plt_ax: Optional[plt.axes] = None,
    plt_show: bool = True,
    show_centroid: bool = False,
    visualization_column: Optional[str] = None,
    bounds_color: Optional[Union[str, np.array, pd.Series]] = None,
    detection_kwargs: dict = {},
    bounds_kwargs: dict = {},
    raster_file: Optional[PATH_TYPE] = None,
    raster_vis_downsample: float = 10.0,
) -> plt.axes:
    """Plot the detections and the bounds of the region

    Args:
        data_frame: (gpd.GeoDataFrame):
            The data representing the detections
        bounds: (gpd.GeoSeries):
            The spatial bounds of the predicted region
        CRS (Optional[pyproj.CRS], optional):
            What CRS to use. Defaults to None.
        plt_ax (Optional[plt.axes], optional):
            A pyplot axes to plot on. If not provided, one will be created. Defaults to None.
        plt_show (bool, optional):
            Whether to plot the result or just return it. Defaults to True.
        show_centroid (bool, optional):
            Whether to plot the centroid points within the boundaries. Defaults to False.
        visualization_column (Optional[str], optional):
            Which column to visualize from the detections dataframe. Defaults to None.
        bounds_color (Optional[Union[str, np.array, pd.Series]], optional):
            The color to plot the bounds. Must be accepted by the gpd.plot color argument.
            Defaults to None.
        detection_kwargs (dict, optional):
            Additional keyword arguments to pass to the .plot method for the detections.
            Defaults to {}.
        bounds_kwargs (dict, optional):
            Additional keyword arguments to pass to the .plot method for the bounds.
            Defaults to {}.
        raster_file (Optional[PATH_TYPE], optional):
            A path to a raster file to visualize the detections over if provided. Defaults to None.
        raster_vis_downsample (float, optional):
            The raster file is downsampled by this fraction before visualization to avoid
            excessive memory use or plotting time. Defaults to 10.0.

    Returns:
        plt.axes: The axes that were plotted on
    """

    # If no axes are provided, create new ones
    if plt_ax is None:
        _, plt_ax = plt.subplots(figsize=(10, 8))

    # Show the raster if provided
    if raster_file is not None:
        show_raster(
            raster_file_path=raster_file,
            downsample_factor=raster_vis_downsample,
            plt_ax=plt_ax,
            CRS=CRS,
        )

    # Plot the detections dataframe and the bounds on the same axes
    if "facecolor" not in detection_kwargs:
        # Plot with transperent faces unless requested
        detection_kwargs["facecolor"] = "none"

    data_frame.plot(
        ax=plt_ax, column=visualization_column, **detection_kwargs, legend=True
    )
    # Use the .boundary attribute to plot just the border. This works since it's a geoseries,
    # not a geodataframe
    bounds.boundary.plot(ax=plt_ax, color=bounds_color, **bounds_kwargs)

    # Plot the centroids if requested
    if show_centroid is True:
        for i, row in data_frame.iterrows():
            centroid = row.geometry.centroid
            x, y = centroid.x, centroid.y
            plt_ax.plot(x, y, "ro", markersize=1)
            plt_ax.text(
                x + 3, y, f"{i:03}", color="red", fontsize=4, ha="left", va="top"
            )

    # Show if requested
    if plt_show:
        plt.show()

    # Return the axes in case they need to be used later
    return plt_ax

reproject_detections(region_detections, target_crs)

Reprojects the RegionDetections object to a target CRS.

Parameters:

Name Type Description Default
region_detections RegionDetections

The detections to reproject.

required
target_crs CRS

The target CRS to reproject to.

required

Returns:

Name Type Description
RegionDetections RegionDetections

Detections after reprojection.

Source code in tree_detection_framework/detection/region_detections.py
def reproject_detections(
    region_detections: RegionDetections,
    target_crs: pyproj.CRS,
) -> RegionDetections:
    """
    Reprojects the RegionDetections object to a target CRS.

    Args:
        region_detections (RegionDetections): The detections to reproject.
        target_crs (pyproj.CRS): The target CRS to reproject to.

    Returns:
        RegionDetections: Detections after reprojection.
    """

    # Get the detections in the target CRS
    projected_detections = region_detections.get_data_frame(CRS=target_crs)
    # Get the bounds in the target CRS
    projected_bounds = region_detections.get_bounds(CRS=target_crs)[0]

    # Create a new RegionDetections object with the projected detections
    projected_region_detections = RegionDetections(
        detection_geometries=None,
        data=projected_detections,
        CRS=target_crs,
        geospatial_prediction_bounds=projected_bounds,
    )

    return projected_region_detections