Source code for locan.rois.roi

"""
The Roi class is an object that defines a region of interest within a
localization dataset. It is therefore related to region specifications
and a unique LocData object.

The Roi object provides methods for saving all specifications to a
yaml file, for loading them, and for returning LocData with
localizations selected to be within the roi region.
"""

from __future__ import annotations

import logging
import os
import re
import sys
import warnings
from ast import literal_eval
from collections.abc import Iterable, Sequence
from inspect import isabstract
from itertools import product
from pathlib import Path
from typing import Any, Literal, TypeVar

import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
import numpy.typing as npt
from google.protobuf import json_format
from matplotlib.widgets import EllipseSelector, PolygonSelector, RectangleSelector
from ruamel.yaml import YAML

import locan.constants
import locan.locan_io.locdata.io_locdata as io
from locan.data import metadata_pb2
from locan.data.locdata import LocData
from locan.data.metadata_utils import _modify_meta
from locan.data.regions import region
from locan.data.regions.region import Region, RoiRegion
from locan.utils.miscellaneous import _get_subclasses

__all__: list[str] = ["Roi", "rasterize", "load_locdata_from_roi_file"]

logger = logging.getLogger(__name__)


class _MplSelector:  # pragma: no cover
    """
    Class to use matplotlib widgets (RectangleSelector, EllipseSelector
    or PolygonSelector) on rendered localization
    data.

    Parameters
    ----------
    ax : matplotlib.axes.Axes
        Axes to use the widget on.
    type : str
        Type is a string specifying the selector widget that can be
        either rectangle, ellipse, or polygon.

    Attributes
    ----------
    rois : list[dict]
        A list of rois where each element is a dict with keys
        `region_specs` and 'region'.
        `region_specs` contain a tuple with specifications for the
        chosen region type (see ``Roi``).
        The region is a string identifier that can be either rectangle,
        ellipse, or polygon.
    """

    def __init__(self, ax: mpl.axes.Axes, type: str = "rectangle") -> None:
        self.rois: list[dict[str, Any]] = []
        self.ax: mpl.axes.Axes = ax

        if type == "rectangle":
            self.selector = RectangleSelector(
                self.ax, self.selector_callback, drawtype="box", interactive=True  # type: ignore[call-arg]
            )
            self.type = type

        elif type == "ellipse":
            self.selector = EllipseSelector(self.ax, self.selector_callback)
            self.type = type

        elif type == "polygon":
            raise NotImplementedError(
                "The polygon selection is not working correctly. Use napari."
            )
        # The PolygonSelector is not working correctly.
        #     self.selector = PolygonSelector(self.ax, self.p_selector_callback)
        #     self.type = type

        else:
            raise TypeError(f"Type {type} is not defined.")

        plt.connect("key_press_event", self.key_pressed_callback)

    def selector_callback(self, eclick: Any, erelease: Any) -> None:
        """eclick and erelease are matplotlib events at press and release."""
        print("startposition: {}".format((eclick.xdata, eclick.ydata)))  # noqa: UP032
        print(
            "endposition  : {}".format((erelease.xdata, erelease.ydata))  # noqa: UP032
        )  # noqa: UP032

    def p_selector_callback(self, vertices: Any) -> None:
        print(f"Vertices: {vertices}")

    def key_pressed_callback(self, event: Any) -> None:
        print("Key pressed.")
        if event.key in ["R", "r"]:
            print("RectangleSelector activated.")
            self.selector = RectangleSelector(
                self.ax, self.selector_callback, drawtype="box", interactive=True  # type: ignore[call-arg]
            )
            self.type = "rectangle"

        elif event.key in ["E", "e"]:
            print("EllipseSelector activated.")
            self.selector = EllipseSelector(
                self.ax, self.selector_callback, drawtype="box", interactive=True  # type: ignore[call-arg]
            )
            self.type = "ellipse"

        elif event.key in ["T", "t"]:
            print("PolygonSelector activated.")
            self.selector = PolygonSelector(self.ax, self.p_selector_callback)  # type: ignore
            self.type = "polygon"

        else:
            pass

        if event.key in ["+"] and self.selector.active and self.type == "rectangle":
            print("Roi was added.")
            region_specs = (
                np.flip(self.selector.geometry[:, 0]),
                self.selector.extents[1] - self.selector.extents[0],
                self.selector.extents[3] - self.selector.extents[2],
                0.0,
            )
            self.rois.append({"region_specs": region_specs, "region": self.type})
            print(f"rois: {self.rois}")

        elif event.key in ["+"] and self.selector.active and self.type == "ellipse":
            print("Roi was added.")
            region_specs = (  # type: ignore[assignment]
                self.selector.center,
                self.selector.extents[1] - self.selector.extents[0],
                self.selector.extents[3] - self.selector.extents[2],
                0.0,
            )

            self.rois.append({"region_specs": region_specs, "region": self.type})
            print(f"rois: {self.rois}")

        elif event.key in ["+"] and self.selector.active and self.type == "polygon":
            print("Roi was added.")
            vertices_ = self.selector.verts.append(self.selector.verts[0])  # type: ignore
            self.rois.append({"region_specs": vertices_, "region": self.type})
            print(f"rois: {self.rois}")

        else:
            pass


T_Roi = TypeVar("T_Roi", bound="Roi")


[docs] class Roi: """ Class for defining a region of interest for a referenced LocData object. Parameters ---------- region : Region Geometrical region of interest. reference : LocData | dict | locan.data.metadata_pb2.Metadata | locan.data.metadata_pb2.File | None Reference to localization data for which the region of interest is defined. It can be a LocData object, a reference to a saved SMLM file, or None for indicating no specific reference. When dict it must have keys `file_path`and `file_type`. When Metadata message it must have keys `file.path` and `file.type` for a path pointing to a localization file and an integer or string indicating the file type. Integer or string should be according to locan.constants.FileType. loc_properties : Sequence[str] | None Localization properties in LocData object on which the region selection will be applied (for instance the coordinate_keys). Attributes ---------- region : Region Geometrical region of interest. reference : LocData | locan.data.metadata_pb2.Metadata | None Reference to localization data for which the region of interest is defined. It can be a LocData object, a reference to a saved SMLM file, or None for indicating no specific reference. When referencing a saved SMLM file, reference has attributes `file.path` and `file.type` for a path pointing to a localization file and an integer indicating the file type. loc_properties : tuple[str, ...] | None Localization properties in LocData object on which the region selection will be applied (for instance the coordinate_keys). """ def __init__( self, region: Region, reference: ( LocData | dict[str, Any] | metadata_pb2.Metadata | metadata_pb2.File | None ) = None, loc_properties: Iterable[str] | None = None, ): self.reference: LocData | metadata_pb2.Metadata | metadata_pb2.File | None if isinstance(reference, dict): self.reference = metadata_pb2.Metadata() self.reference.file.path = str(reference["file_path"]) ft_ = reference["file_type"] if isinstance(reference["file_type"], int): self.reference.file.type = ft_ elif isinstance(reference["file_type"], str): self.reference.file.type = locan.constants.FileType[ft_.upper()].value # type: ignore[assignment] elif isinstance(reference["file_type"], locan.constants.FileType): self.reference.file.type = ft_ elif isinstance(reference["file_type"], metadata_pb2): # type: ignore self.reference.file.type = ft_.file.type else: raise TypeError elif isinstance(reference, metadata_pb2.Metadata): self.reference = metadata_pb2.Metadata() self.reference.file.MergeFrom(reference.file) elif isinstance(reference, metadata_pb2.File): self.reference = metadata_pb2.Metadata() self.reference.file.MergeFrom(reference) else: self.reference = reference self.region = region if loc_properties is None: self.loc_properties: Sequence[str] = () else: self.loc_properties = loc_properties # type: ignore def __repr__(self) -> str: return ( f"Roi(reference={self.reference}, " f"region={repr(self.region)}, " f" loc_properties={self.loc_properties})" ) @property def region(self) -> Region: return self._region @region.setter def region(self, region_: Region) -> None: if not isinstance(region_, Region): raise TypeError("An instance of locan.Region must be provided.") self._region = region_
[docs] def to_yaml(self, path: str | os.PathLike[str] | None = None) -> None: """ Save Roi object in yaml format. Parameters ---------- path Path for yaml file. If None a roi file path is generated from the metadata. """ # prepare path if path is None and isinstance(self.reference, LocData): _file_path = Path(self.reference.meta.file.path) _roi_file = _file_path.stem + "_roi.yaml" _path = _file_path.with_name(_roi_file) elif path is None and isinstance(self.reference, metadata_pb2.Metadata): _file_path = Path(self.reference.file.path) _roi_file = _file_path.stem + "_roi.yaml" _path = _file_path.with_name(_roi_file) else: assert path is not None # type narrowing # noqa: S101 _path = Path(path) # prepare reference for yaml representation, # since reference to LocData cannot be represented if self.reference is None: reference_for_yaml = None elif isinstance(self.reference, LocData): if self.reference.meta.file.path: meta_ = metadata_pb2.Metadata() meta_.file.path = self.reference.meta.file.path meta_.file.type = self.reference.meta.file.type reference_for_yaml = json_format.MessageToJson( meta_, always_print_fields_with_no_presence=False ) else: warnings.warn( "The localization data has to be saved and the file path provided, " "or the reference is lost.", UserWarning, stacklevel=1, ) reference_for_yaml = None else: reference_for_yaml = json_format.MessageToJson( self.reference, always_print_fields_with_no_presence=False ) region_for_yaml = repr(self.region) loc_properties_for_yaml = self.loc_properties yaml = YAML() output = dict( reference=reference_for_yaml, region=region_for_yaml, loc_properties=loc_properties_for_yaml, ) yaml.dump(output, _path)
[docs] @classmethod def from_yaml( cls: type[T_Roi], path: str | os.PathLike[Any] # noqa: UP006 ) -> T_Roi: # noqa: UP006 """ Read Roi object from yaml format. Parameters ---------- path Path for yaml file. """ yaml = YAML(typ="safe") with open(path) as file: yaml_output = yaml.load(file) # noqa: S506 if yaml_output["reference"] is not None: reference_ = metadata_pb2.Metadata() reference_ = json_format.Parse(yaml_output["reference"], reference_) else: reference_ = yaml_output["reference"] # type: ignore[assignment] region_names = [ cls.__name__ for cls in _get_subclasses(Region) if not isabstract(cls) ] pattern = "|".join(region_names) match = re.match(pattern=pattern, string=yaml_output["region"]) if match: cls_name = match.group() remainder = match.string[match.end() :] parameters = literal_eval(remainder) if isinstance(parameters, tuple): region_ = getattr(region, cls_name)(*parameters) else: region_ = getattr(region, cls_name)(parameters) else: raise ValueError("Region is not specified in yaml file.") loc_properties_ = yaml_output["loc_properties"] return cls(reference=reference_, region=region_, loc_properties=loc_properties_)
[docs] def locdata(self, reduce: bool = True) -> LocData: """ Localization data according to roi specifications. The ROI is applied on locdata properties as specified in self.loc_properties or by taking the first applicable locdata.coordinate_keys. Parameters ---------- reduce Return the reduced LocData object or keep references alive. Returns ------- LocData A new instance of LocData with all localizations within region of interest. """ local_parameter = locals() if isinstance(self.reference, LocData): locdata = self.reference elif isinstance(self.reference, metadata_pb2.Metadata): locdata = io.load_locdata( self.reference.file.path, self.reference.file.type ) else: raise AttributeError("Valid reference to locdata is missing.") if not len(locdata): logger.warning("Locdata in reference is empty.") new_locdata = locdata else: if self.loc_properties: pfr = self.loc_properties else: pfr = locdata.coordinate_keys[0 : self.region.dimension] points = locdata.data[list(pfr)].values indices_inside = self._region.contains(points) locdata_indices_to_keep = locdata.data.index[indices_inside] new_locdata = LocData.from_selection( locdata=locdata, indices=locdata_indices_to_keep ) if pfr == new_locdata.coordinate_keys: new_locdata.region = self._region else: new_locdata.region = None # finish if reduce: new_locdata.reduce() # update metadata meta_ = _modify_meta( locdata, new_locdata, function_name=sys._getframe().f_code.co_name, parameter=local_parameter, meta=None, ) new_locdata.meta = meta_ return new_locdata
[docs] class RoiLegacy_0: """ Class for a region of interest on LocData (roi). Roi objects define a region of interest for a referenced LocData object. Parameters ---------- reference : LocData, dict, locan.data.metadata_pb2.Metadata, None Reference to localization data for which the region of interests are defined. It can be a LocData object, a reference to a saved SMLM file, or None for indicating no specific reference. When referencing a saved SMLM file, reference must be a dict or locan.data.metadata_pb2.Metadata with keys `file_path` and `file_type` for a path pointing to a localization file and an integer or string indicating the file type. Integer or string should be according to locan.constants.FileType. region_type : str A string indicating the roi shape. In 1D it can be `interval`. In 2D it can be either `rectangle`, `ellipse`, or closed `polygon`. In 3D it can be either `cuboid` or `ellipsoid` or `polyhedron` (not implemented yet). region_specs : tuple 1D rois are defined by the following tuple: * interval: (start, stop) 2D rois are defined by the following tuples: * rectangle: ((corner_x, corner_y), width, height, angle) * ellipse: ((center_x, center_y), width, height, angle) * polygon: ((point1_x, point1_y), (point2_x, point2_y), ..., (point1_x, point1_y)) 3D rois are defined by the following tuples: * cuboid: ((corner_x, corner_y, corner_z), length, width, height, angle_1, angle_2, angle_3) * ellipsoid: ((center_x, center_y, center_z), length, width, height, angle_1, angle_2, angle_3) * polyhedron: (...) properties_for_roi : tuple[str, ...] Localization properties in LocData object on which the region selection will be applied (for instance the coordinate_keys). Attributes ---------- reference : LocData | locan.data.metadata_pb2.Metadata | None Reference to localization data for which the regions of interest are defined. It can be a LocData object, a reference (locan.data.metadata_pb2.Metadata) to a saved SMLM file, or None for indicating no specific reference. When referencing a saved SMLM file, reference as attributes `file_path` and `file_type` for a path pointing to a localization file and an integer indicating the file type. The integer should be according to locan.data.metadata_pb2.Metadata.file_type. _region : RoiRegion | list[RoiRegion] Object specifying the geometrical region of interest. In case a list of RoiRegion is provided it is the union that makes up the region of interest. properties_for_roi : tuple[str, ...] Localization properties in LocData object on which the region selection will be applied (for instance the coordinate_keys). Warnings -------- `RoiLegacy` is deprecated and should only be used to read legacy _roi.yaml files. Use :class:`locan.Roi` instead. """ def __init__( # type: ignore self, region_type, region_specs, reference=None, properties_for_roi=() ) -> None: if isinstance(reference, dict): self.reference = metadata_pb2.Metadata() self.reference.file.path = str(reference["file_path"]) ft_ = reference["file_type"] if isinstance(reference["file_type"], int): self.reference.file.type = ft_ elif isinstance(reference["file_type"], str): self.reference.file.type = locan.constants.FileType[ft_.upper()].value # type: ignore elif isinstance(reference["file_type"], locan.constants.FileType): self.reference.file.type = ft_ elif isinstance(reference["file_type"], metadata_pb2): # type: ignore self.reference.file.type = ft_.file.type else: raise TypeError elif isinstance(reference, metadata_pb2.Metadata): self.reference = metadata_pb2.Metadata() self.reference.MergeFrom(reference) else: self.reference = reference self._region = RoiRegion(region_type=region_type, region_specs=region_specs) self.properties_for_roi = properties_for_roi def __repr__(self) -> str: return ( f"Roi(reference={self.reference}, " f"region={self._region.region_type}, " f"region_specs={self._region.region_specs}," f" loc_properties={self.properties_for_roi})" ) @property def region(self): # type: ignore return self._region @region.setter def region(self, region_): # type: ignore if isinstance(region_, RoiRegion): self._region = region_
[docs] def to_yaml(self, path=None): # type: ignore """ Save Roi object in yaml format. Parameters ---------- path : str | os.PathLike | None Path for yaml file. If None a roi file path is generated from the metadata. """ warnings.warn( "RoiLegacy.to_yaml is deprecated, use Roi.to_yaml instead", DeprecationWarning, stacklevel=2, ) # prepare path if path is None and isinstance(self.reference, LocData): _file_path = Path(self.reference.meta.file.path) _roi_file = _file_path.stem + "_roi.yaml" _path = _file_path.with_name(_roi_file) elif path is None and isinstance(self.reference, metadata_pb2.Metadata): _file_path = Path(self.reference.file.path) _roi_file = _file_path.stem + "_roi.yaml" _path = _file_path.with_name(_roi_file) else: _path = Path(path) # prepare reference for yaml representation - reference to LocData cannot be represented if self.reference is None: reference_for_yaml = None elif isinstance(self.reference, LocData): if self.reference.meta.file.path: meta_ = metadata_pb2.Metadata() meta_.file.path = self.reference.meta.file.path meta_.file.type = self.reference.meta.file.type reference_for_yaml = json_format.MessageToJson( meta_, always_print_fields_with_no_presence=False ) else: warnings.warn( "The localization data has to be saved and the file path provided, " "or the reference is lost.", UserWarning, stacklevel=1, ) reference_for_yaml = None else: reference_for_yaml = json_format.MessageToJson( self.reference, always_print_fields_with_no_presence=False ) # prepare points for yaml representation - numpy.float has to be converted to float def nested_change(iterable, func): # type: ignore if isinstance(iterable, (list, tuple, np.ndarray)): return [nested_change(x, func) for x in iterable] # type: ignore return func(iterable) region_specs_for_yaml = nested_change(self._region.region_specs, float) # type: ignore region_type_for_yaml = self._region.region_type properties_for_roi_for_yaml = self.properties_for_roi yaml = YAML() output = dict( reference=reference_for_yaml, region_type=region_type_for_yaml, region_specs=region_specs_for_yaml, properties_for_roi=properties_for_roi_for_yaml, ) yaml.dump(output, _path)
[docs] @classmethod def from_yaml(cls, path): # type: ignore """ Read Roi object from yaml format. Parameters ---------- path : str | os.PathLike Path for yaml file. """ yaml = YAML(typ="safe") with open(path) as file: yaml_output = yaml.load(file) # noqa: S506 if yaml_output["reference"] is not None: reference_ = metadata_pb2.Metadata() reference_ = json_format.Parse(yaml_output["reference"], reference_) else: reference_ = yaml_output["reference"] # type: ignore[assignment] region_type_ = yaml_output["region_type"] region_specs_ = yaml_output["region_specs"] properties_for_roi_ = yaml_output["properties_for_roi"] return cls( reference=reference_, region_type=region_type_, region_specs=region_specs_, properties_for_roi=properties_for_roi_, )
[docs] def locdata(self, reduce=True): # type: ignore """ Localization data according to roi specifications. The ROI is applied on locdata properties as specified in self.loc_properties or by taking the first applicable locdata.coordinate_keys. Parameters ---------- reduce : bool Return the reduced LocData object or keep references alive. Returns ------- LocData A new instance of LocData with all localizations within region of interest. """ local_parameter = locals() if isinstance(self.reference, LocData): locdata = self.reference elif isinstance(self.reference, metadata_pb2.Metadata): locdata = io.load_locdata( self.reference.file_path, self.reference.file_type # type: ignore ) else: raise AttributeError("Valid reference to locdata is missing.") if not len(locdata): logger.warning("Locdata in reference is empty.") new_locdata = locdata else: if self.properties_for_roi: pfr = self.properties_for_roi else: pfr = locdata.coordinate_keys[0 : self._region.dimension] points = locdata.data[list(pfr)].values indices_inside = self._region.contains(points) locdata_indices_to_keep = locdata.data.index[indices_inside] new_locdata = LocData.from_selection( locdata=locdata, indices=locdata_indices_to_keep ) if pfr == new_locdata.coordinate_keys: new_locdata.region = self._region # type: ignore else: new_locdata.region = None # finish if reduce: new_locdata.reduce() # update metadata meta_ = _modify_meta( locdata, new_locdata, function_name=sys._getframe().f_code.co_name, parameter=local_parameter, meta=None, ) new_locdata.meta = meta_ return new_locdata
# todo generalize to take all loc_properties
[docs] def rasterize( locdata: LocData, support: tuple[tuple[int], ...] | None = None, n_regions: tuple[int, ...] = (2, 2, 2), loc_properties: Iterable[str] = (), ) -> tuple[Roi, ...]: """ Provide regions of interest by dividing the locdata support in equally sized rectangles. Parameters ---------- locdata The localization data from which to select localization data. support Coordinate intervals that are divided in `n_regions` subintervals. For None intervals are taken from the bounding box. n_regions Number of regions in each dimension. E.g. `n_regions` = (2, 2) returns 4 rectangular Roi objects. loc_properties Localization properties in LocData object on which the region selection will be applied. (Only implemented for coordinates labels) Returns ------- tuple[Roi, ...] A sequence of Roi objects """ if len(locdata) == 0: raise ValueError("Not implemented for empty LocData objects.") if not set(loc_properties).issubset(locdata.coordinate_keys): raise ValueError("loc_properties must be tuple with coordinate labels.") if loc_properties: coordinate_labels_indices: list[int] = [ locdata.coordinate_keys.index(pfr) for pfr in loc_properties ] else: coordinate_labels_indices = list(range(len(n_regions))) # specify support if support is None: assert locdata.bounding_box is not None # type narrowing # noqa: S101 support_: tuple[tuple[int], ...] | npt.NDArray[Any] = ( locdata.bounding_box.vertices[coordinate_labels_indices] ) if len(locdata.bounding_box.width) == 0: widths = np.zeros(len(n_regions)) else: widths = locdata.bounding_box.width[coordinate_labels_indices] / n_regions # type: ignore else: support_ = support widths = np.diff(support_).flatten() / n_regions # type: ignore # specify interval corners corners_ = [ np.linspace(*support_d, n_regions_d, endpoint=False) # type: ignore for support_d, n_regions_d in zip(support_, n_regions) ] corners = product(*corners_) # specify regions if len(n_regions) == 2: regions = [region.Rectangle(corner, *widths, 0) for corner in corners] # type: ignore elif len(n_regions) == 3: raise NotImplementedError("Computation for 3D has not been implemented, yet.") else: raise ValueError("The shape of n_regions is incompatible.") new_rois = tuple( [ Roi(reference=locdata, region=reg, loc_properties=loc_properties) for reg in regions ] ) return new_rois
[docs] def load_locdata_from_roi_file( path: str | os.PathLike[Any], reference_path: str | os.PathLike[Any] | Literal["path"] | None = None, ) -> LocData: """ A helper function to load localization data according to the chosen roi file. Parameters ---------- path File path for a file to load. reference_path Taken as new roi reference path but with the localization file name. If "path", reference path will be set to path but with the localization file name Returns ------- LocData A new instance of LocData with all localizations. See Also -------- :func:`locan.rois.roi.Roi.from_yaml()` :func:`locan.rois.roi.Roi.locdata()` """ path = Path(path) roi = Roi.from_yaml(path=path) if reference_path == "path": roi.reference.file.path = str( # type: ignore[union-attr] path.with_name(Path(roi.reference.file.path).name) # type: ignore[union-attr] ) elif reference_path is not None: roi.reference.file.path = str( # type: ignore[union-attr] Path(reference_path).with_name(Path(roi.reference.file.path).name) # type: ignore[union-attr] ) locdata = roi.locdata() return locdata