# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
Classes for a segmentation image and a single segment within a
segmentation image.
"""
import inspect
import warnings
from collections import defaultdict
from copy import copy, deepcopy
from functools import cached_property
import numpy as np
from astropy.utils.exceptions import AstropyUserWarning
from scipy.ndimage import find_objects, grey_dilation
from scipy.signal import fftconvolve
from photutils.aperture import BoundingBox
from photutils.aperture.region_converters import _shapely_polygon_to_region
from photutils.utils._deprecation import (deprecated_getattr,
deprecated_positional_kwargs)
from photutils.utils._optional_deps import HAS_RASTERIO, HAS_SHAPELY
from photutils.utils._parameters import as_pair
from photutils.utils.colormaps import make_random_cmap
__all__ = ['Segment', 'SegmentationImage']
# Remove in 4.0
_SEGM_DEPRECATED_ATTRIBUTES = {
'nlabels': 'n_labels',
'data_ma': 'data_masked',
'deblended_labels_map': 'deblended_label_to_parent',
'deblended_labels_inverse_map': 'parent_to_deblended_labels',
}
# Remove in 4.0
_SEGMENT_DEPRECATED_ATTRIBUTES = {
'data_ma': 'data_masked',
}
def _get_labels(array, *, return_counts=False):
"""
Return the sorted non-zero values in ``array``.
Parameters
----------
array : `~numpy.ndarray`
An array of label values. It may have any shape (e.g., a full
segmentation array or already-extracted values).
return_counts : bool, optional
If `True`, also return the number of occurrences of each
label.
Returns
-------
labels : `~numpy.ndarray`
The sorted non-zero label values. `numpy.unique` preserves the
input dtype.
counts : `~numpy.ndarray`, optional
The number of occurrences of each label, in the same order as
``labels``. Only returned if ``return_counts`` is `True`.
"""
return np.unique(array[array != 0], return_counts=return_counts)
def _remap_deblend_label_map(deblend_label_map, relabel_map):
"""
Return a new deblend label map with remapped child labels.
Parameters
----------
deblend_label_map : dict
The mapping of parent label numbers to arrays of deblended
(child) label numbers.
relabel_map : 1D `~numpy.ndarray`
An array mapping the original label numbers to the new label
numbers.
Returns
-------
result : dict
A new mapping with the same parent keys, where each array of
child labels has been translated through ``relabel_map``.
"""
return {parent_label: relabel_map[child_labels]
for parent_label, child_labels in deblend_label_map.items()}
def _remap_flags_map(flags_map, relabel_map):
"""
Return a new per-label flags map with remapped labels.
Labels that map to zero are dropped. Labels that map to the same new
label have their flags combined with a bitwise OR.
Parameters
----------
flags_map : dict
The mapping of label numbers to bitwise flag values.
relabel_map : 1D `~numpy.ndarray`
An array mapping the original label numbers to the new label
numbers.
Returns
-------
result : dict
A new mapping of the remapped label numbers to their combined
flag values.
"""
new_map = {}
for label, flags in flags_map.items():
new_label = int(relabel_map[label])
if new_label == 0:
continue
new_map[new_label] = new_map.get(new_label, 0) | flags
return new_map
[docs]
class SegmentationImage:
"""
Class for a segmentation image.
Parameters
----------
data : 2D int `~numpy.ndarray`
A 2D segmentation array where source regions are labeled by
different positive integer values. A value of zero is reserved
for the background. The segmentation image must have integer
type.
Attributes
----------
flags : `~numpy.ndarray`
A 1D array of per-label bitwise quality flags, in the same
order as the ``labels`` attribute. The flags record deblending
provenance and are all zero for segmentation images not produced
by :func:`~photutils.segmentation.deblend_sources`. Use
:func:`~photutils.segmentation.decode_segmentation_flags` to
decode the values. The flags are reset to zero when the ``data``
attribute is reassigned, and slicing returns a new instance with
all flags reset to zero.
info : dict
A dictionary containing auxiliary information about the
segmentation image. For example, segmentation images returned
by :func:`~photutils.segmentation.deblend_sources` store
the input labels affected by deblending warnings under
``'nonposmin_labels'`` and ``'n_markers_labels'`` keys. The
dictionary is empty if there is no auxiliary information. It
is reset to an empty dictionary when the ``data`` attribute is
reassigned.
Notes
-----
The `SegmentationImage` instance may be sliced, but note that the
sliced `SegmentationImage` data array will be a view into the
original `SegmentationImage` array (this is the same behavior as
`~numpy.ndarray`). Explicitly use the :meth:`SegmentationImage.copy`
method to create a copy of the sliced `SegmentationImage`.
"""
def __init__(self, data):
self.data = data
@classmethod
def _from_data(cls, data, *, labels=None, areas=None, slices=None,
deblend_label_map=None):
"""
Create a `SegmentationImage` from a pre-validated segmentation
array, optionally seeding cached properties.
This bypasses the input validation performed by ``__init__``
and is used internally where the input array is already
known to be a valid segmentation image (e.g., the
outputs of `~photutils.segmentation.detect_sources` and
`~photutils.segmentation.deblend_sources`).
Parameters
----------
data : 2D int `~numpy.ndarray`
The valid 2D segmentation array.
labels : `~numpy.ndarray`, optional
The sorted non-zero labels in ``data``, used to seed the
``labels`` cached property.
areas : `~numpy.ndarray`, optional
The pixel area of each label, in the same order as
``labels``, used to seed the ``areas`` cached property.
slices : list of tuple of slice, optional
The slices for each label, used to seed the ``slices``
cached property. The list order must match ``labels``.
deblend_label_map : dict, optional
The mapping of parent label numbers to deblended label
numbers. If `None`, an empty mapping is used.
Returns
-------
segment_img : `SegmentationImage`
The new `SegmentationImage` instance.
"""
segm = object.__new__(cls)
segm._set_data(data, labels=labels, areas=areas, slices=slices)
if deblend_label_map is not None:
segm._deblend_label_map = deblend_label_map
return segm
def __str__(self):
cls_name = f'<{self.__class__.__module__}.{self.__class__.__name__}>'
params = ['shape', 'n_labels']
cls_info = [(param, getattr(self, param)) for param in params]
cls_info.append(('labels', self.labels))
with np.printoptions(threshold=25, edgeitems=5):
fmt = [f'{key}: {val}' for key, val in cls_info]
return f'{cls_name}\n' + '\n'.join(fmt)
def __repr__(self):
return self.__str__()
# Remove in 4.0
def __getattr__(self, name):
return deprecated_getattr(self, name, _SEGM_DEPRECATED_ATTRIBUTES,
since='3.0', until='4.0')
def __getitem__(self, key):
"""
Slice the segmentation image, returning a new SegmentationImage
object.
"""
if (isinstance(key, tuple) and len(key) == 2
and all(isinstance(key[i], slice) for i in (0, 1))):
result = self.data[key]
if result.size == 0:
msg = ('The sliced result is empty; cannot create '
'a SegmentationImage with zero size')
raise ValueError(msg)
return SegmentationImage(result)
msg = f'{key!r} is not a valid 2D slice object'
raise TypeError(msg)
def __setattr__(self, name, value):
"""
Set an attribute.
The segmentation array is write protected because the derived
cached properties must be reset whenever it changes. Use the
``data`` attribute or :meth:`_set_data` instead.
"""
if name == '_data':
msg = ('Direct assignment to _data is not allowed. Assign '
'to the data attribute or call _set_data()')
raise AttributeError(msg)
super().__setattr__(name, value)
def __array__(self):
"""
Array representation of the segmentation array (e.g., for
matplotlib).
"""
return self._data
[docs]
@cached_property
def segments(self):
"""
A list of `Segment` objects.
The list starts with the *non-zero* label. The returned list has
a length equal to the number of labels and matches the order of
the ``labels`` attribute.
"""
segments = []
if HAS_RASTERIO and HAS_SHAPELY:
for label, slc, bbox, area, polygon in zip(self.labels,
self.slices,
self.bbox,
self.areas,
self.polygons,
strict=True):
segments.append(Segment(self.data, label, slc, bbox, area,
polygon=polygon))
else:
for label, slc, bbox, area in zip(self.labels, self.slices,
self.bbox, self.areas,
strict=True):
segments.append(Segment(self.data, label, slc, bbox, area))
return segments
[docs]
@cached_property
def deblended_labels(self):
"""
A sorted 1D array of deblended label numbers.
The list will be empty if deblending has not been performed or
if no sources were deblended.
"""
if len(self._deblend_label_map) == 0:
return np.array([], dtype=self._data.dtype)
return np.sort(np.concatenate(list(self._deblend_label_map.values())))
[docs]
@cached_property
def deblended_label_to_parent(self):
"""
A dictionary mapping deblended label numbers to the original
parent label numbers.
The keys are the deblended label numbers and the values are the
original parent label numbers. Only deblended sources are
included in the dictionary.
The dictionary will be empty if deblending has not been
performed or if no sources were deblended.
"""
inverse_map = {}
for key, values in self._deblend_label_map.items():
for value in values:
inverse_map[value] = key
return inverse_map
[docs]
@cached_property
def parent_to_deblended_labels(self):
"""
A dictionary mapping the original parent label numbers to the
deblended label numbers.
The keys are the original parent label numbers and the values
are the deblended label numbers. Only deblended sources are
included in the dictionary.
The dictionary will be empty if deblending has not been
performed or if no sources were deblended.
"""
return self._deblend_label_map
@property
def flags(self):
"""
A 1D array of per-label bitwise quality flags, in the same order
as the ``labels`` attribute.
The flags are set by `~photutils.segmentation.deblend_sources`
to record deblending provenance. They are all zero for
directly-constructed segmentation images and for the
output of `~photutils.segmentation.detect_sources`. Use
`~photutils.segmentation.decode_segmentation_flags` to decode
the values. The flags are reset to zero when the ``data``
attribute is reassigned. Slicing the segmentation image (e.g.,
``segm[10:50, 10:50]``) returns a new instance with all flags
reset to zero as well.
"""
return np.array([self._flags_map.get(int(label), 0)
for label in self.labels], dtype=int)
@property
def data(self):
"""
The segmentation array.
"""
return self._data
@property
def _cached_properties(self):
"""
A list of all class cached properties (even in superclasses).
The result is cached on the class to avoid repeated
introspection via `inspect.getmembers`.
"""
cls = self.__class__
attr = '_cached_properties_cache'
# Subclasses get their own cached-property list
if attr not in cls.__dict__:
def is_cached_property(obj):
return isinstance(obj, cached_property)
setattr(cls, attr,
[i[0] for i in inspect.getmembers(
cls, predicate=is_cached_property)])
return getattr(cls, attr)
def _reset_cached_properties(self):
for key in self._cached_properties:
self.__dict__.pop(key, None)
def _set_data(self, data, *, labels=None, areas=None, slices=None,
preserve_info=False):
"""
Set the segmentation array and seed its derived properties.
This is the only method that assigns the ``_data`` attribute.
It performs no validation. The caller is responsible for
passing a valid integer segmentation array.
Any derived value that is not supplied is computed on first
access. Supplied values are trusted and are not checked against
``data``.
Parameters
----------
data : 2D int `~numpy.ndarray`
The valid 2D segmentation array.
labels : 1D int `~numpy.ndarray`, optional
The sorted non-zero labels in ``data``.
areas : 1D int `~numpy.ndarray`, optional
The pixel area of each label, in the same order as
``labels``.
slices : list of tuple of slice, optional
The minimal bounding slices of each label, in the same
order as ``labels``.
preserve_info : bool, optional
If `True`, keep the current ``info`` dictionary instead of
resetting it to an empty one. This is used by the
relabeling methods, where the change is an in-place
relabeling rather than a data reassignment.
"""
if '_data' in self.__dict__:
# Reset cached properties when data is reassigned, but not
# on init
self._reset_cached_properties()
# Bypass __setattr__, which rejects direct _data assignment
object.__setattr__(self, '_data', data)
# Seed the known derived values. functools.cached_property
# reads the instance dictionary first, so a seeded entry is
# returned as-is and an absent one falls through to the
# property body.
for name, value in (('labels', labels), ('areas', areas),
('slices', slices)):
if value is not None:
self.__dict__[name] = value
# Reset the deblended label map, per-label flags map, and
# auxiliary info explicitly since _deblend_label_map,
# _flags_map, and info are regular attributes, not cached
# properties cleared by _reset_cached_properties above.
self.__dict__['_deblend_label_map'] = {}
self.__dict__['_flags_map'] = {}
if not preserve_info:
self.__dict__['info'] = {}
@data.setter
def data(self, value):
if not isinstance(value, np.ndarray):
msg = 'Input data must be a numpy array'
raise TypeError(msg)
if isinstance(value, np.ma.MaskedArray):
msg = 'Input data must not be a numpy masked array'
raise TypeError(msg)
if not np.issubdtype(value.dtype, np.integer):
msg = 'data must have integer type'
raise TypeError(msg)
# A single pass over the non-zero pixels yields both the
# sorted labels and their pixel areas
labels, areas = _get_labels(value, return_counts=True)
# labels is sorted, so only the first element can be negative.
# The size check also covers all-zero and zero-size arrays.
if labels.size and labels[0] < 0:
msg = 'The segmentation image cannot contain negative integers.'
raise ValueError(msg)
self._set_data(value, labels=labels, areas=areas)
[docs]
@cached_property
def data_masked(self):
"""
A `~numpy.ma.MaskedArray` version of the segmentation array
where the background (label = 0) has been masked.
"""
return np.ma.masked_where(self.data == 0, self.data)
[docs]
@cached_property
def shape(self):
"""
The shape of the segmentation array.
"""
return self._data.shape
[docs]
@cached_property
def labels(self):
"""
The sorted non-zero labels in the segmentation array.
"""
# Normally seeded by _set_data. This runs only when the array
# was set without known labels.
return _get_labels(self._data)
[docs]
@cached_property
def n_labels(self):
"""
The number of non-zero labels in the segmentation array.
"""
return len(self.labels)
[docs]
@cached_property
def max_label(self):
"""
The maximum label in the segmentation array.
"""
if self.n_labels == 0:
return 0
return np.max(self.labels)
[docs]
def get_index(self, label):
"""
Find the index of the input ``label``.
Array input is also accepted, in which case an array of
indices is returned. :meth:`get_indices` delegates to this
method.
Parameters
----------
label : int or 1D array_like (int)
The label number(s) to find.
Returns
-------
index : int or 1D int `~numpy.ndarray`
The array index or indices. If ``label`` is a scalar, then
the returned index will also be a scalar.
Raises
------
ValueError
If ``label`` is invalid.
"""
self.check_labels(label)
# self.labels is always sorted
return np.searchsorted(self.labels, label)
[docs]
def get_indices(self, labels):
"""
Find the indices of the input ``labels``.
Parameters
----------
labels : int, array_like (1D, int)
The label numbers(s) to find.
Returns
-------
indices : int `~numpy.ndarray`
An integer array of indices with the same shape as
``labels``. If ``labels`` is a scalar, then the returned
index will also be a scalar.
Raises
------
ValueError
If any input ``labels`` are invalid.
"""
return self.get_index(labels)
[docs]
@cached_property
def slices(self):
"""
A list of tuples, where each tuple contains two slices
representing the minimal box that contains the labeled region.
The list starts with the *non-zero* label. The returned list has
a length equal to the number of labels and matches the order of
the ``labels`` attribute.
"""
return [slc for slc in find_objects(self._data)
if slc is not None]
def _get_slice(self, label):
"""
Return the bounding slice for a single label.
The input ``label`` must already be known to be valid. Use
:meth:`check_labels` first if that is not guaranteed.
Parameters
----------
label : int
The label number.
Returns
-------
slc : tuple of slice
The minimal bounding slice containing the labeled region.
"""
# self.labels is sorted, so searchsorted gives the position in
# the slices list, which matches the labels order
return self.slices[np.searchsorted(self.labels, label)]
[docs]
@cached_property
def bbox(self):
"""
A list of `~photutils.aperture.BoundingBox` of the minimal
bounding boxes containing the labeled regions.
"""
if self._data.ndim != 2:
msg = "The 'bbox' attribute requires a 2D segmentation image."
raise ValueError(msg)
return [BoundingBox(ixmin=slc[1].start, ixmax=slc[1].stop,
iymin=slc[0].start, iymax=slc[0].stop)
for slc in self.slices]
[docs]
@cached_property
def background_area(self):
"""
The area (in pixel**2) of the background (label=0) region.
"""
return self._data.size - np.count_nonzero(self._data)
[docs]
@cached_property
def areas(self):
"""
A 1D array of areas (in pixel**2) of the non-zero labeled
regions.
The `~numpy.ndarray` starts with the *non-zero* label. The
returned array has a length equal to the number of labels and
matches the order of the ``labels`` attribute.
"""
# Normally seeded by _set_data from the same single pass that
# produces the labels. This runs only when the array was set
# without known areas.
return _get_labels(self._data, return_counts=True)[1]
[docs]
def get_area(self, label):
"""
The area (in pixel**2) of the region for the input label.
Parameters
----------
label : int
The label whose area to return. Label must be non-zero.
Returns
-------
area : float
The area of the labeled region.
"""
return self.get_areas(label)[0]
[docs]
def get_areas(self, labels):
"""
The areas (in pixel**2) of the regions for the input labels.
Parameters
----------
labels : int, 1D array_like (int)
The label(s) for which to return areas. Label must be
non-zero.
Returns
-------
areas : `~numpy.ndarray`
The areas of the labeled regions.
"""
idx = self.get_indices(np.atleast_1d(labels))
return self.areas[idx]
def _make_polygon(self, label, slc):
"""
Create a Shapely polygon for a single label using only its
bounding-box cutout.
Parameters
----------
label : int
The label number.
slc : tuple of slices
The slice for the bounding box of the label.
Returns
-------
polygon : `shapely.Polygon` or `shapely.MultiPolygon` or `None`
A Shapely Polygon or MultiPolygon, or `None` if rasterio and
shapely are not available.
"""
if not (HAS_RASTERIO and HAS_SHAPELY):
return None
if slc is None:
return None
from rasterio.features import shapes
from rasterio.transform import Affine
from shapely import MultiPolygon
from shapely.geometry import shape
cutout = self._data[slc]
# Create a mask for only this label within the cutout
label_mask = (cutout == label)
# Shift the vertices so that the (0, 0) origin is at the
# center of the lower-left pixel, offset by the slice origin
y0 = slc[0].start
x0 = slc[1].start
transform = Affine(1.0, 0.0, x0 - 0.5, 0.0, 1.0, y0 - 0.5)
# Create a single-label array for the cutout
label_data = np.where(label_mask, label, 0).astype(np.int32)
raw_polys = list(shapes(label_data, connectivity=8,
mask=label_mask, transform=transform))
geo_polys = [poly for poly, val in raw_polys if int(val) == label]
if len(geo_polys) == 0:
return None
if len(geo_polys) == 1:
return shape(geo_polys[0])
return MultiPolygon([shape(poly) for poly in geo_polys])
def _make_segment(self, label):
"""
Create a single `Segment` object for the given label.
Parameters
----------
label : int
The label number.
Returns
-------
segment : `Segment`
The segment object.
"""
label = self._data.dtype.type(label)
slc = self._get_slice(label)
bbox = BoundingBox(ixmin=slc[1].start, ixmax=slc[1].stop,
iymin=slc[0].start, iymax=slc[0].stop)
area = np.count_nonzero(self._data[slc] == label)
polygon = self._make_polygon(label, slc)
return Segment(self.data, label, slc, bbox, area, polygon=polygon)
[docs]
def get_segment(self, label):
"""
Return a `Segment` object for the given label.
This is significantly faster than ``segments[index]`` for
segmentation images with many labels because it constructs only
the requested `Segment` without building the full list.
Parameters
----------
label : int
The segment label number.
Returns
-------
segment : `Segment`
The segment object for the input label.
Raises
------
TypeError
If ``label`` is not a scalar.
ValueError
If ``label`` is invalid.
"""
if np.ndim(label) != 0:
msg = 'label must be a scalar value'
raise TypeError(msg)
self.check_labels(label)
return self._make_segment(label)
[docs]
def get_segments(self, labels):
"""
Return a list of `Segment` objects for the given labels.
This is significantly faster than indexing into ``segments``
when only a subset of labels is needed because it constructs
only the requested `Segment` objects without building the full
list.
Parameters
----------
labels : int, array_like (1D, int)
The label number(s) for which to return `Segment` objects.
Returns
-------
segments : list of `Segment`
A list of `Segment` objects in the same order as the input
``labels``.
Raises
------
ValueError
If any input ``labels`` are invalid.
"""
labels = np.atleast_1d(labels)
self.check_labels(labels)
return [self._make_segment(label) for label in labels]
[docs]
@cached_property
def is_consecutive(self):
"""
Boolean value indicating whether the non-zero labels in the
segmentation array are consecutive and start from 1.
"""
if self.n_labels == 0:
return False
return ((self.labels[-1] - self.labels[0] + 1) == self.n_labels
and self.labels[0] == 1)
[docs]
@cached_property
def missing_labels(self):
"""
A 1D `~numpy.ndarray` of the sorted non-zero labels that are
missing in the consecutive sequence from one to the maximum
label number.
"""
if self.n_labels == 0:
return np.array([], dtype=self._data.dtype)
present = np.zeros(self.max_label + 1, dtype=bool)
present[self.labels] = True
present[0] = True # exclude 0 from missing
return np.where(~present)[0].astype(self._data.dtype)
[docs]
def copy(self):
"""
Return a deep copy of this object.
Returns
-------
result : `SegmentationImage`
A deep copy of this object.
"""
return deepcopy(self)
[docs]
def check_label(self, label):
"""
Check that the input label is a valid label number within the
segmentation array.
Parameters
----------
label : int
The label number to check.
Raises
------
ValueError
If the input ``label`` is invalid.
"""
self.check_labels(label)
[docs]
def check_labels(self, labels):
"""
Check that the input label(s) are valid label numbers within the
segmentation array.
Parameters
----------
labels : int, 1D array_like (int)
The label(s) to check.
Raises
------
ValueError
If any input ``labels`` are invalid.
"""
labels = np.atleast_1d(labels)
bad_labels = set()
# Check if label is in the segmentation array
valid_mask = np.isin(labels, self.labels)
bad_labels.update(labels[~valid_mask])
if bad_labels:
# Convert numpy scalars to Python ints for a clean message
bad_labels = sorted(int(label) for label in bad_labels)
label_str = 'label'
conj_str = 'is'
if len(bad_labels) > 1:
label_str = 'labels'
conj_str = 'are'
msg = f'{label_str} {bad_labels} {conj_str} invalid'
raise ValueError(msg)
def _make_cmap(self, n_colors, *, background_color='#000000ff',
seed=None):
"""
Define a matplotlib colormap consisting of (random) muted
colors.
This is useful for plotting the segmentation array.
Parameters
----------
n_colors : int
The number of the colors in the colormap.
background_color : Matplotlib color, optional
The color of the first color in the colormap.
The color may be specified using any of
the `Matplotlib color formats
<https://matplotlib.org/stable/tutorials/colors/colors.html>`_.
This color will be used as the background color (label = 0)
when plotting the segmentation image. The default color is
black with alpha=1.0 ('#000000ff').
seed : int, optional
A seed to initialize the `numpy.random.BitGenerator`. If
`None`, then fresh, unpredictable entropy will be pulled
from the OS. Separate function calls with the same ``seed``
will generate the same colormap.
Returns
-------
cmap : `matplotlib.colors.ListedColormap`
The matplotlib colormap with colors in RGBA format.
"""
if self.n_labels == 0:
return None
from matplotlib import colors
cmap = make_random_cmap(n_colors=n_colors, seed=seed)
if background_color is not None:
cmap.colors[0] = colors.to_rgba(background_color)
return cmap
[docs]
@deprecated_positional_kwargs(since='3.0', until='4.0')
def make_cmap(self, background_color='#000000ff', seed=None):
"""
Define a matplotlib colormap consisting of (random) muted
colors.
This is useful for plotting the segmentation array.
Parameters
----------
background_color : Matplotlib color, optional
The color of the first color in the colormap.
The color may be specified using any of
the `Matplotlib color formats
<https://matplotlib.org/stable/tutorials/colors/colors.html>`_.
This color will be used as the background color (label = 0)
when plotting the segmentation image. The default color is
black with alpha=1.0 ('#000000ff').
seed : int, optional
A seed to initialize the `numpy.random.BitGenerator`. If
`None`, then fresh, unpredictable entropy will be pulled
from the OS. Separate function calls with the same ``seed``
will generate the same colormap.
Returns
-------
cmap : `matplotlib.colors.ListedColormap`
The matplotlib colormap with colors in RGBA format.
"""
return self._make_cmap(self.max_label + 1,
background_color=background_color,
seed=seed)
[docs]
@deprecated_positional_kwargs(since='3.0', until='4.0')
def reset_cmap(self, seed=None):
"""
Reset the colormap (`cmap` attribute) to a new random colormap.
Parameters
----------
seed : int, optional
A seed to initialize the `numpy.random.BitGenerator`. If
`None`, then fresh, unpredictable entropy will be pulled
from the OS. Separate function calls with the same ``seed``
will generate the same colormap.
"""
self.cmap = self.make_cmap(background_color='#000000ff', seed=seed)
[docs]
@cached_property
def cmap(self):
"""
A matplotlib colormap consisting of (random) muted colors.
This is useful for plotting the segmentation array.
"""
return self.make_cmap(background_color='#000000ff', seed=0)
[docs]
@deprecated_positional_kwargs(since='3.0', until='4.0')
def reassign_label(self, label, new_label, relabel=False):
"""
Reassign a label number to a new number.
If ``new_label`` is already present in the segmentation array,
then it will be combined with the input ``label`` number.
Note that this can result in a label that is no longer pixel
connected.
Parameters
----------
label : int
The label number to reassign.
new_label : int
The newly assigned label number.
relabel : bool, optional
If `True`, then the segmentation array will be relabeled
such that the labels are in consecutive order starting from
1.
Examples
--------
>>> from photutils.segmentation import SegmentationImage
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> segm.reassign_label(label=1, new_label=2)
>>> segm.data
array([[2, 2, 0, 0, 4, 4],
[0, 0, 0, 0, 0, 4],
[0, 0, 3, 3, 0, 0],
[7, 0, 0, 0, 0, 5],
[7, 7, 0, 5, 5, 5],
[7, 7, 0, 0, 5, 5]])
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> segm.reassign_label(label=1, new_label=4)
>>> segm.data
array([[4, 4, 0, 0, 4, 4],
[0, 0, 0, 0, 0, 4],
[0, 0, 3, 3, 0, 0],
[7, 0, 0, 0, 0, 5],
[7, 7, 0, 5, 5, 5],
[7, 7, 0, 0, 5, 5]])
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> segm.reassign_label(label=1, new_label=4, relabel=True)
>>> segm.data
array([[2, 2, 0, 0, 2, 2],
[0, 0, 0, 0, 0, 2],
[0, 0, 1, 1, 0, 0],
[4, 0, 0, 0, 0, 3],
[4, 4, 0, 3, 3, 3],
[4, 4, 0, 0, 3, 3]])
"""
self.reassign_labels(label, new_label, relabel=relabel)
[docs]
@deprecated_positional_kwargs(since='3.0', until='4.0')
def reassign_labels(self, labels, new_label, relabel=False):
"""
Reassign one or more label numbers.
Multiple input ``labels`` will all be reassigned to the same
``new_label`` number. If ``new_label`` is already present in
the segmentation array, then it will be combined with the input
``labels``. Note that both of these can result in a label that
is no longer pixel connected.
Parameters
----------
labels : int, array_like (1D, int)
The label numbers(s) to reassign.
new_label : int
The reassigned label number.
relabel : bool, optional
If `True`, then the segmentation array will be relabeled
such that the labels are in consecutive order starting from
1.
Examples
--------
>>> from photutils.segmentation import SegmentationImage
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> segm.reassign_labels(labels=[1, 7], new_label=2)
>>> segm.data
array([[2, 2, 0, 0, 4, 4],
[0, 0, 0, 0, 0, 4],
[0, 0, 3, 3, 0, 0],
[2, 0, 0, 0, 0, 5],
[2, 2, 0, 5, 5, 5],
[2, 2, 0, 0, 5, 5]])
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> segm.reassign_labels(labels=[1, 7], new_label=4)
>>> segm.data
array([[4, 4, 0, 0, 4, 4],
[0, 0, 0, 0, 0, 4],
[0, 0, 3, 3, 0, 0],
[4, 0, 0, 0, 0, 5],
[4, 4, 0, 5, 5, 5],
[4, 4, 0, 0, 5, 5]])
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> segm.reassign_labels(labels=[1, 7], new_label=2, relabel=True)
>>> segm.data
array([[1, 1, 0, 0, 3, 3],
[0, 0, 0, 0, 0, 3],
[0, 0, 2, 2, 0, 0],
[1, 0, 0, 0, 0, 4],
[1, 1, 0, 4, 4, 4],
[1, 1, 0, 0, 4, 4]])
"""
self.check_labels(labels)
labels = np.atleast_1d(labels)
if labels.size == 0:
return
dtype = self.data.dtype # keep the original dtype
relabel_map = np.zeros(self.max_label + 1, dtype=dtype)
relabel_map[self.labels] = self.labels
relabel_map[labels] = new_label # reassign labels
if relabel:
labels = np.unique(relabel_map[relabel_map != 0])
if len(labels) != 0:
map2 = np.zeros(max(labels) + 1, dtype=dtype)
map2[labels] = np.arange(len(labels), dtype=dtype) + 1
relabel_map = map2[relabel_map]
data_new = relabel_map[self.data]
# Relabeling is an in-place change, not a data reassignment,
# so the auxiliary info, the deblending provenance, and the
# per-label flags are kept. The local references are needed
# because _set_data rebinds _deblend_label_map and _flags_map to
# new empty dicts.
deblend_label_map = self._deblend_label_map
flags_map = self._flags_map
self._set_data(data_new, preserve_info=True)
self._deblend_label_map = _remap_deblend_label_map(
deblend_label_map, relabel_map)
self._flags_map = _remap_flags_map(flags_map, relabel_map)
[docs]
@deprecated_positional_kwargs(since='3.0', until='4.0')
def relabel_consecutive(self, start_label=1):
"""
Reassign the label numbers consecutively starting from a given
label number.
Parameters
----------
start_label : int, optional
The starting label number, which should be a strictly
positive integer. The default is 1.
Examples
--------
>>> from photutils.segmentation import SegmentationImage
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> segm.relabel_consecutive()
>>> segm.data
array([[1, 1, 0, 0, 3, 3],
[0, 0, 0, 0, 0, 3],
[0, 0, 2, 2, 0, 0],
[5, 0, 0, 0, 0, 4],
[5, 5, 0, 4, 4, 4],
[5, 5, 0, 0, 4, 4]])
"""
if self.n_labels == 0:
msg = 'Cannot relabel a segmentation image with no non-zero labels'
warnings.warn(msg, AstropyUserWarning)
return
if start_label <= 0:
msg = 'start_label must be > 0'
raise ValueError(msg)
if ((self.labels[0] == start_label)
and (self.labels[-1] - self.labels[0] + 1) == self.n_labels):
return
old_slices = self.__dict__.get('slices', None)
old_areas = self.__dict__.get('areas', None)
dtype = self.data.dtype # keep the original dtype
new_labels = np.arange(self.n_labels, dtype=dtype) + start_label
new_label_map = np.zeros(self.max_label + 1, dtype=dtype)
new_label_map[self.labels] = new_labels
data_new = new_label_map[self.data]
# Relabeling is an in-place change, not a data reassignment,
# so the auxiliary info, the deblending provenance, and the
# per-label flags are kept. The local references are needed
# because _set_data rebinds _deblend_label_map and _flags_map to
# new empty dicts.
deblend_label_map = self._deblend_label_map
flags_map = self._flags_map
# Relabeling is order-preserving, so the areas and slices are
# unchanged and carry over under the new label numbers
self._set_data(data_new, labels=new_labels, areas=old_areas,
slices=old_slices, preserve_info=True)
self._deblend_label_map = _remap_deblend_label_map(
deblend_label_map, new_label_map)
self._flags_map = _remap_flags_map(flags_map, new_label_map)
[docs]
@deprecated_positional_kwargs(since='3.0', until='4.0')
def keep_label(self, label, relabel=False):
"""
Keep only the specified label.
Parameters
----------
label : int
The label number to keep.
relabel : bool, optional
If `True`, then the single segment will be assigned a label
value of 1.
Examples
--------
>>> from photutils.segmentation import SegmentationImage
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> segm.keep_label(label=3)
>>> segm.data
array([[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 3, 3, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0]])
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> segm.keep_label(label=3, relabel=True)
>>> segm.data
array([[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0]])
"""
self.keep_labels(label, relabel=relabel)
[docs]
@deprecated_positional_kwargs(since='3.0', until='4.0')
def keep_labels(self, labels, relabel=False):
"""
Keep only the specified labels.
Parameters
----------
labels : int, array_like (1D, int)
The label number(s) to keep.
relabel : bool, optional
If `True`, then the segmentation array will be relabeled
such that the labels are in consecutive order starting from
1.
Examples
--------
>>> from photutils.segmentation import SegmentationImage
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> segm.keep_labels(labels=[5, 3])
>>> segm.data
array([[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 3, 3, 0, 0],
[0, 0, 0, 0, 0, 5],
[0, 0, 0, 5, 5, 5],
[0, 0, 0, 0, 5, 5]])
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> segm.keep_labels(labels=[5, 3], relabel=True)
>>> segm.data
array([[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 0, 0],
[0, 0, 0, 0, 0, 2],
[0, 0, 0, 2, 2, 2],
[0, 0, 0, 0, 2, 2]])
"""
self.check_labels(labels)
labels = np.atleast_1d(labels)
labels_tmp = np.setdiff1d(self.labels, labels)
self.remove_labels(labels_tmp, relabel=relabel)
[docs]
@deprecated_positional_kwargs(since='3.0', until='4.0')
def remove_label(self, label, relabel=False):
"""
Remove the label number.
The removed label is assigned a value of zero (i.e.,
background).
Parameters
----------
label : int
The label number to remove.
relabel : bool, optional
If `True`, then the segmentation array will be relabeled
such that the labels are in consecutive order starting from
1.
Examples
--------
>>> from photutils.segmentation import SegmentationImage
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> segm.remove_label(label=5)
>>> segm.data
array([[1, 1, 0, 0, 4, 4],
[0, 0, 0, 0, 0, 4],
[0, 0, 3, 3, 0, 0],
[7, 0, 0, 0, 0, 0],
[7, 7, 0, 0, 0, 0],
[7, 7, 0, 0, 0, 0]])
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> segm.remove_label(label=5, relabel=True)
>>> segm.data
array([[1, 1, 0, 0, 3, 3],
[0, 0, 0, 0, 0, 3],
[0, 0, 2, 2, 0, 0],
[4, 0, 0, 0, 0, 0],
[4, 4, 0, 0, 0, 0],
[4, 4, 0, 0, 0, 0]])
"""
self.remove_labels(label, relabel=relabel)
[docs]
@deprecated_positional_kwargs(since='3.0', until='4.0')
def remove_labels(self, labels, relabel=False):
"""
Remove one or more labels.
Removed labels are assigned a value of zero (i.e., background).
Parameters
----------
labels : int, array_like (1D, int)
The label number(s) to remove.
relabel : bool, optional
If `True`, then the segmentation array will be relabeled
such that the labels are in consecutive order starting from
1.
Examples
--------
>>> from photutils.segmentation import SegmentationImage
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> segm.remove_labels(labels=[5, 3])
>>> segm.data
array([[1, 1, 0, 0, 4, 4],
[0, 0, 0, 0, 0, 4],
[0, 0, 0, 0, 0, 0],
[7, 0, 0, 0, 0, 0],
[7, 7, 0, 0, 0, 0],
[7, 7, 0, 0, 0, 0]])
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> segm.remove_labels(labels=[5, 3], relabel=True)
>>> segm.data
array([[1, 1, 0, 0, 2, 2],
[0, 0, 0, 0, 0, 2],
[0, 0, 0, 0, 0, 0],
[3, 0, 0, 0, 0, 0],
[3, 3, 0, 0, 0, 0],
[3, 3, 0, 0, 0, 0]])
"""
self.check_labels(labels)
self.reassign_labels(labels, new_label=0, relabel=relabel)
[docs]
@deprecated_positional_kwargs(since='3.0', until='4.0')
def remove_border_labels(self, border_width, partial_overlap=True,
relabel=False):
"""
Remove labeled segments near the array border.
Labels within the defined border region will be removed.
Parameters
----------
border_width : int
The width of the border region in pixels. It must be
positive and smaller than half the array size in any
dimension.
partial_overlap : bool, optional
If this is set to `True` (the default), a segment that
partially extends into the border region will be removed.
Segments that are completely within the border region are
always removed.
relabel : bool, optional
If `True`, then the segmentation array will be relabeled
such that the labels are in consecutive order starting from
1.
Examples
--------
>>> from photutils.segmentation import SegmentationImage
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> segm.remove_border_labels(border_width=1)
>>> segm.data
array([[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 3, 3, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0]])
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> segm.remove_border_labels(border_width=1,
... partial_overlap=False)
>>> segm.data
array([[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 3, 3, 0, 0],
[7, 0, 0, 0, 0, 5],
[7, 7, 0, 5, 5, 5],
[7, 7, 0, 0, 5, 5]])
"""
if border_width <= 0:
msg = 'border_width must be a positive integer'
raise ValueError(msg)
if border_width >= min(self.shape) / 2:
msg = ('border_width must be smaller than half the array size '
'in any dimension')
raise ValueError(msg)
border_mask = np.zeros(self.shape, dtype=bool)
for i in range(border_mask.ndim):
border_mask = border_mask.swapaxes(0, i)
border_mask[:border_width] = True
border_mask[-border_width:] = True
border_mask = border_mask.swapaxes(0, i)
self.remove_masked_labels(border_mask,
partial_overlap=partial_overlap,
relabel=relabel)
[docs]
@deprecated_positional_kwargs(since='3.0', until='4.0')
def remove_masked_labels(self, mask, partial_overlap=True,
relabel=False):
"""
Remove labeled segments located within a masked region.
Parameters
----------
mask : array_like (bool)
A boolean mask, with the same shape as the segmentation
array, where `True` values indicate masked pixels.
partial_overlap : bool, optional
If this is set to `True` (default), a segment that partially
extends into a masked region will also be removed. Segments
that are completely within a masked region are always
removed.
relabel : bool, optional
If `True`, then the segmentation array will be relabeled
such that the labels are in consecutive order starting from
1.
Examples
--------
>>> from photutils.segmentation import SegmentationImage
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> mask = np.zeros(segm.data.shape, dtype=bool)
>>> mask[0, :] = True # mask the first row
>>> segm.remove_masked_labels(mask)
>>> segm.data
array([[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 3, 3, 0, 0],
[7, 0, 0, 0, 0, 5],
[7, 7, 0, 5, 5, 5],
[7, 7, 0, 0, 5, 5]])
>>> data = np.array([[1, 1, 0, 0, 4, 4],
... [0, 0, 0, 0, 0, 4],
... [0, 0, 3, 3, 0, 0],
... [7, 0, 0, 0, 0, 5],
... [7, 7, 0, 5, 5, 5],
... [7, 7, 0, 0, 5, 5]])
>>> segm = SegmentationImage(data)
>>> segm.remove_masked_labels(mask, partial_overlap=False)
>>> segm.data
array([[0, 0, 0, 0, 4, 4],
[0, 0, 0, 0, 0, 4],
[0, 0, 3, 3, 0, 0],
[7, 0, 0, 0, 0, 5],
[7, 7, 0, 5, 5, 5],
[7, 7, 0, 0, 5, 5]])
"""
if mask.shape != self.shape:
msg = 'mask must have the same shape as the segmentation array'
raise ValueError(msg)
remove_labels = _get_labels(self.data[mask])
if not partial_overlap:
interior_labels = _get_labels(self.data[~mask])
remove_labels = list(set(remove_labels)
- set(interior_labels))
self.remove_labels(remove_labels, relabel=relabel)
[docs]
def make_source_mask(self, *, size=None, footprint=None):
"""
Make a source mask from the segmentation image.
Use the ``size`` or ``footprint`` keyword to perform binary
dilation on the segmentation image mask.
Parameters
----------
size : int or tuple of int, optional
The size along each axis of the rectangular footprint used
for the source dilation. If ``size`` is a scalar, then a
square footprint of ``size`` will be used. If ``size`` has
two elements, they must be in ``(ny, nx)`` order. ``size``
should have odd values for each axis. To perform source
dilation, either ``size`` or ``footprint`` must be defined.
If they are both defined, then ``footprint`` overrides
``size``.
footprint : 2D `~numpy.ndarray`, optional
The local footprint used for the source dilation. Non-zero
elements are considered `True`. ``size=(n, m)`` is
equivalent to ``footprint=np.ones((n, m))``. To perform
source dilation, either ``size`` or ``footprint`` must
be defined. If they are both defined, then ``footprint``
overrides ``size``.
Returns
-------
mask : 2D bool `~numpy.ndarray`
A 2D boolean image containing the source mask.
Notes
-----
When performing source dilation, using a square footprint
will be much faster than using other shapes (e.g., a circular
footprint). Source dilation also is slower for larger images and
larger footprints.
Examples
--------
>>> import numpy as np
>>> from photutils.segmentation import SegmentationImage
>>> from photutils.utils import circular_footprint
>>> data = np.zeros((7, 7), dtype=int)
>>> data[3, 3] = 1
>>> segm = SegmentationImage(data)
>>> segm.data
array([[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0]])
>>> mask0 = segm.make_source_mask()
>>> mask0
array([[False, False, False, False, False, False, False],
[False, False, False, False, False, False, False],
[False, False, False, False, False, False, False],
[False, False, False, True, False, False, False],
[False, False, False, False, False, False, False],
[False, False, False, False, False, False, False],
[False, False, False, False, False, False, False]])
>>> mask1 = segm.make_source_mask(size=3)
>>> mask1
array([[False, False, False, False, False, False, False],
[False, False, False, False, False, False, False],
[False, False, True, True, True, False, False],
[False, False, True, True, True, False, False],
[False, False, True, True, True, False, False],
[False, False, False, False, False, False, False],
[False, False, False, False, False, False, False]])
>>> footprint = circular_footprint(radius=3)
>>> mask2 = segm.make_source_mask(footprint=footprint)
>>> mask2
array([[False, False, False, True, False, False, False],
[False, True, True, True, True, True, False],
[False, True, True, True, True, True, False],
[ True, True, True, True, True, True, True],
[False, True, True, True, True, True, False],
[False, True, True, True, True, True, False],
[False, False, False, True, False, False, False]])
"""
mask = self._data.astype(bool)
if footprint is None:
if size is None:
return mask
size = as_pair('size', size, check_odd=False)
footprint = np.ones(size, dtype=bool)
footprint = footprint.astype(bool)
if np.all(footprint):
# With a rectangular footprint, scipy's grey_dilation is
# currently much faster than binary_dilation (separable
# footprint). grey_dilation and binary_dilation are identical
# for binary inputs (equivalent to a 2D maximum filter).
return grey_dilation(mask, footprint=footprint)
# Binary dilation is very slow, especially for large
# footprints. The following is a faster implementation
# using fast Fourier transforms (FFTs) that gives identical
# results to binary_dilation. Based on the following paper:
# "Dilation and Erosion of Gray Images with Spherical
# Masks", J. Kukal, D. Majerova, A. Prochazka (Jan 2007).
# https://www.researchgate.net/publication/238778666_DILATION_AND_EROSION_OF_GRAY_IMAGES_WITH_SPHERICAL_MASKS
return fftconvolve(mask, footprint, 'same') > 0.5
@cached_property
def _geojson_polygons(self):
"""
A dictionary of GeoJSON-like polygons representing each source
segment.
The keys are the unique label numbers in the segmentation image,
and the values are lists of polygons for each label.
Each item in the dictionary is list containing tuples of
(polygon, value) where the polygon is a GeoJSON-like dict and
the value is the label from the segmentation image. Non-
contiguous segments for a single label will have multiple tuples
in the list (e.g., from slicing the segmentation image where a
segment label is split into non-contiguous segments). Segments
with holes will have a single tuple with a polygon containing
the outer ring and the inner rings (holes) as a list of lists.
Note that the coordinates of these polygon vertices are
transformed to a reference frame with the (0, 0) origin at the
center of the lower-left pixel. This is done by shifting the
vertices by 0.5 pixels in both x and y directions, so that the
origin is at the center of the lower-left pixel. By default,
rasterio and GeoJSON use the corner of the lower-left pixel as
the origin, which is not compatible with the pixel coordinates
used in Photutils.
"""
from rasterio.features import shapes
from rasterio.transform import Affine
rasterio_int_dtypes = {np.dtype('uint8'), np.dtype('int8'),
np.dtype('uint16'), np.dtype('int16'),
np.dtype('int32')}
# Try to convert the data to int32 if it has an unsupported
# dtype
if self.data.dtype not in rasterio_int_dtypes:
min_val, max_val = self.data.min(), self.data.max()
int32_info = np.iinfo(np.int32)
if min_val >= int32_info.min and max_val <= int32_info.max:
dtype = np.int32
else:
msg = (f'The segmentation image dtype is {self.data.dtype} '
'with values outside the safe np.int32 range '
f'[{int32_info.min}, {int32_info.max}]. The rasterio '
'library cannot create polygons in this case. You may '
'try to relabel your data to fit within an int32 '
'range.')
raise ValueError(msg)
else:
dtype = self.data.dtype
# Shift the vertices so that the (0, 0) origin is at the
# center of the lower-left pixel
transform = Affine(1.0, 0.0, -0.5, 0.0, 1.0, -0.5)
mask = self.data > 0 # mask out the background pixels
polygons = list(shapes(self.data.astype(dtype), connectivity=8,
mask=mask, transform=transform))
polygons.sort(key=lambda x: x[1]) # sort in label order
# Group polygons by label
polygon_dict = defaultdict(list)
for polygon, label in polygons:
polygon_dict[int(label)].append(polygon)
# Check that the polygon labels match the segmentation image
# labels; this is a sanity check to ensure that the rasterio
# library is working correctly.
# Note that polygons have been sorted by label.
if not np.all(np.array(list(polygon_dict.keys())) == self.labels):
msg = ('The segmentation image labels do not match the '
'polygon labels. This may be due to a bug in the '
'rasterio library or an unexpected data type in the '
'segmentation image.')
raise ValueError(msg)
return polygon_dict
[docs]
@cached_property
def polygons(self):
"""
A list of `Shapely <https://shapely.readthedocs.io/en/stable/>`_
polygons representing each source segment.
Polygon or MultiPolygon objects are returned, depending on
whether the source segment is a single polygon or multiple
polygons (e.g., holes or non-contiguous) for the same label.
"""
from shapely import MultiPolygon
from shapely.geometry import shape
polygons = []
for label, geo_polys in self._geojson_polygons.items():
if len(geo_polys) == 0:
msg = f'Could not create a polygon for label {label}'
raise ValueError(msg)
if len(geo_polys) == 1:
polygons.append(shape(geo_polys[0]))
elif len(geo_polys) > 1:
# Merge multiple polygons for the same label
polys = [shape(poly) for poly in geo_polys]
polygons.append(MultiPolygon(polys))
# NOTE: the returned polygons may return False for
# is_valid due to ring self-intersections (e.g.,
# for corner-only intersections of two pixels). The
# shapely.validation.explain_validity function can be
# used to explain the validity of the polygons. The
# shapely.validation.make_valid function can be used to make the
# polygons valid, usually by converting Polygon objects into
# MultiPolyon objects.
return polygons
[docs]
def get_polygon(self, label):
"""
Return the `Shapely
<https://shapely.readthedocs.io/en/stable/>`_ polygon for the
given label.
Parameters
----------
label : int
The label number.
Returns
-------
polygon : `shapely.Polygon` or `shapely.MultiPolygon` or `None`
A Shapely Polygon or MultiPolygon object, or `None` if
rasterio and shapely are not available.
Raises
------
TypeError
If ``label`` is not a scalar.
ValueError
If ``label`` is invalid.
"""
if np.ndim(label) != 0:
msg = 'label must be a scalar value'
raise TypeError(msg)
return self.get_polygons(label)[0]
[docs]
def get_polygons(self, labels):
"""
Return a list of `Shapely
<https://shapely.readthedocs.io/en/stable/>`_ polygons for the
given labels.
Parameters
----------
labels : int, array_like (1D, int)
The label number(s).
Returns
-------
polygons : list of `shapely.Polygon`, `shapely.MultiPolygon`, \
or `None`
A list of Shapely Polygon or MultiPolygon objects, or `None`
elements if rasterio and shapely are not available.
Raises
------
ValueError
If any input ``labels`` are invalid.
"""
labels = np.atleast_1d(labels)
self.check_labels(labels)
return [self._make_polygon(label, self._get_slice(label))
for label in labels]
@staticmethod
def _convert_ring_to_path(ring):
"""
Helper function to process a single Shapely ring (exterior or
interior) into vertices and Matplotlib path codes.
"""
from matplotlib import path
coords = np.array(ring.coords)
# A closed polygon path in Matplotlib starts with MOVETO,
# is followed by LINETO for each subsequent vertex,
# and ends with a CLOSEPOLY.
codes = ([path.Path.MOVETO] + [path.Path.LINETO] * (len(coords) - 2)
+ [path.Path.CLOSEPOLY])
return coords, codes
def _convert_shapely_to_pathpatch(self, geometry, *, origin=(0, 0),
scale=1.0, **kwargs):
"""
Create a single Matplotlib PathPatch from a Shapely geometry.
Parameters
----------
geometry : `shapely.geometry.base.BaseGeometry`
The Shapely geometry to convert to a PathPatch.
**kwargs : dict, optional
Any keyword arguments accepted by
`matplotlib.patches.PathPatch`.
Returns
-------
patch : `matplotlib.patches.PathPatch` or `None`
A Matplotlib PathPatch representing the geometry, or `None`
if the geometry is empty.
"""
from matplotlib import path
from matplotlib.patches import PathPatch
if geometry.is_empty:
return None
if geometry.geom_type == 'Polygon':
polygons = [geometry]
else:
polygons = list(geometry.geoms)
all_vertices = []
all_codes = []
for poly in polygons:
# For each polygon, process its exterior and all its
# interior rings. This loop structure avoids repeating the
# call to the helper function.
for ring in [poly.exterior, *list(poly.interiors)]:
vertices, codes = self._convert_ring_to_path(ring)
vertices = scale * (vertices + 0.5) - 0.5
vertices -= origin
all_vertices.append(vertices)
all_codes.extend(codes)
if not all_vertices:
return None
final_path = path.Path(np.concatenate(all_vertices), all_codes)
return PathPatch(final_path, **kwargs)
[docs]
def to_patches(self, *, origin=(0, 0), scale=1.0, **kwargs):
"""
Return a list of `~matplotlib.patches.PathPatch` objects
representing each source segment.
By default, the patch will have a white edge color and no face
color.
Parameters
----------
origin : array_like, optional
The ``(x, y)`` position of the origin of the displayed
image. This effectively translates the position of the
polygons.
scale : float, optional
The scale factor applied to the polygon vertices.
**kwargs : dict, optional
Any keyword arguments accepted by
`matplotlib.patches.PathPatch`.
Returns
-------
patches : list of `~matplotlib.patches.PathPatch`
A list of matplotlib patches for the source segments.
"""
origin = np.array(origin)
patch_kwargs = {'edgecolor': 'white', 'facecolor': 'none'}
patch_kwargs.update(kwargs)
return [self._convert_shapely_to_pathpatch(geometry, origin=origin,
scale=scale, **patch_kwargs)
for geometry in self.polygons]
[docs]
def get_patch(self, label, *, origin=(0, 0), scale=1.0, **kwargs):
"""
Return a `~matplotlib.patches.PathPatch` for the given label.
By default, the patch will have a white edge color and no face
color.
Parameters
----------
label : int
The label number.
origin : array_like, optional
The ``(x, y)`` position of the origin of the displayed
image. This effectively translates the position of the
polygon.
scale : float, optional
The scale factor applied to the polygon vertices.
**kwargs : dict, optional
Any keyword arguments accepted by
`matplotlib.patches.PathPatch`.
Returns
-------
patch : `~matplotlib.patches.PathPatch` or `None`
A matplotlib patch for the source segment, or `None` if the
geometry is empty or rasterio and shapely are not available.
Raises
------
TypeError
If ``label`` is not a scalar.
ValueError
If ``label`` is invalid.
"""
if np.ndim(label) != 0:
msg = 'label must be a scalar value'
raise TypeError(msg)
return self.get_patches(label, origin=origin, scale=scale, **kwargs)[0]
[docs]
def get_patches(self, labels, *, origin=(0, 0), scale=1.0, **kwargs):
"""
Return a list of `~matplotlib.patches.PathPatch` objects for the
given labels.
By default, the patches will have a white edge color and no face
color.
Parameters
----------
labels : int, array_like (1D, int)
The label number(s).
origin : array_like, optional
The ``(x, y)`` position of the origin of the displayed
image. This effectively translates the position of the
polygons.
scale : float, optional
The scale factor applied to the polygon vertices.
**kwargs : dict, optional
Any keyword arguments accepted by
`matplotlib.patches.PathPatch`.
Returns
-------
patches : list of `~matplotlib.patches.PathPatch`
A list of matplotlib patches for the source segments.
Raises
------
ValueError
If any input ``labels`` are invalid.
"""
labels = np.atleast_1d(labels)
self.check_labels(labels)
origin = np.array(origin)
patch_kwargs = {'edgecolor': 'white', 'facecolor': 'none'}
patch_kwargs.update(kwargs)
patches = []
for label in labels:
poly = self._make_polygon(label, self._get_slice(label))
patches.append(self._convert_shapely_to_pathpatch(
poly, origin=origin, scale=scale, **patch_kwargs))
return patches
[docs]
def plot_patches(self, *, ax=None, origin=(0, 0), scale=1.0, labels=None,
**kwargs):
"""
Plot the `~matplotlib.patches.PathPatch` objects for the source
segments on a matplotlib `~matplotlib.axes.Axes` instance.
Parameters
----------
ax : `matplotlib.axes.Axes` or `None`, optional
The matplotlib axes on which to plot. If `None`, then the
current `~matplotlib.axes.Axes` instance is used.
origin : array_like, optional
The ``(x, y)`` position of the origin of the displayed
image.
scale : float, optional
The scale factor applied to the polygon vertices.
labels : int or array of int, optional
The label numbers whose polygons are to be plotted. If
`None`, the polygons for all labels will be plotted.
**kwargs : dict, optional
Any keyword arguments accepted by
`matplotlib.patches.PathPatch`.
Returns
-------
patches : list of `~matplotlib.patches.PathPatch`
A list of matplotlib patches for the plotted polygons. The
patches can be used, for example, when adding a plot legend.
Examples
--------
.. plot::
:include-source:
import numpy as np
from photutils.segmentation import SegmentationImage
data = np.array([[1, 1, 0, 0, 4, 4],
[0, 0, 0, 0, 0, 4],
[0, 0, 3, 3, 0, 0],
[7, 0, 0, 0, 0, 5],
[7, 7, 0, 5, 5, 5],
[7, 7, 0, 0, 5, 5]])
segm = SegmentationImage(data)
segm.imshow(figsize=(5, 5))
segm.plot_patches(edgecolor='white', lw=2)
"""
import matplotlib.pyplot as plt
if ax is None:
ax = plt.gca()
patches = self.to_patches(origin=origin, scale=scale, **kwargs)
if labels is not None:
patches = np.array(patches)
indices = self.get_indices(labels)
patches = patches[indices]
if np.isscalar(labels):
patches = [patches]
for patch in patches:
patch = copy(patch)
ax.add_patch(patch)
if labels is not None:
patches = list(patches)
return patches
[docs]
def to_regions(self, *, group=False, **kwargs):
"""
Return the `regions.Region` objects representing the source
segments.
The returned polygon region objects are defined as the exteriors
of the source segments. Interior holes within the source
segments are not included.
See the ``group`` keyword below for details about how
non-contiguous segments for a single label are handled.
Parameters
----------
group : bool, optional
If `False` (the default), then a `regions.Regions`
object will be returned with a flattened list of
`~regions.PolygonPixelRegion` objects. Note that in this
case, there will be multiple `~regions.PolygonPixelRegion`
objects for a single label if the label has non-contiguous
segments. Because of this, the number of regions returned
may not be equal to the number of unique labels in the
segmentation image.
If `True`, then a list of `~regions.PolygonPixelRegion`
or `~regions.Regions` objects will be returned. There
will be one item in the list for each label. If a
label has non-contiguous segments, then the item will
be a `~regions.Regions` object containing multiple
`~regions.PolygonPixelRegion` objects for that label.
**kwargs : dict, optional
Any keyword arguments accepted by
`regions.RegionVisual`. Common keywords include
``edgecolor``, ``facecolor``, ``color``, ``linewidth``,
and ``linestyle``.
Returns
-------
regions : `~regions.Regions`
A list of `~regions.Region` objects or a `~regions.Regions`
object, depending on the value of ``group`` (see above).
Notes
-----
If ``group=False``, then the number of regions returned may not
be equal to the number of unique labels in the segmentation
image. This occurs when the segmentation image contains
non-contiguous segments for a single label. That can happen as a
result of slicing the segmentation image where a segment label
is split into non-contiguous segments.
The meta attribute of the `~regions.PolygonPixelRegion` objects
will contain the label number as an integer value under the
'label' key. This can be used to identify the label of the
region.
"""
from regions import Regions
visual_kwargs = kwargs or None
regions = []
for label, poly in zip(self.labels, self.polygons, strict=True):
regions.append(_shapely_polygon_to_region(
poly, label=int(label), visual_kwargs=visual_kwargs))
if group:
return regions
# If group=False, return a Regions object with a flattened list
# of region objects
flat_regions = []
for region in regions:
if isinstance(region, Regions):
flat_regions.extend(region.regions)
else:
flat_regions.append(region)
return Regions(flat_regions)
[docs]
def get_region(self, label, **kwargs):
"""
Return the `regions <https://astropy-regions.readthedocs.io>`_
region object for the given label.
The returned polygon region is defined as the exterior of the
source segment. Interior holes within the source segment are not
included.
Parameters
----------
label : int
The label number.
**kwargs : dict, optional
Any keyword arguments accepted by
`regions.RegionVisual`. Common keywords include
``edgecolor``, ``facecolor``, ``color``, ``linewidth``,
and ``linestyle``.
Returns
-------
region : `~regions.PolygonPixelRegion` or `~regions.Regions`
A `~regions.PolygonPixelRegion` object, or a
`~regions.Regions` object if the segment is a MultiPolygon
(e.g., non-contiguous).
Raises
------
TypeError
If ``label`` is not a scalar.
ValueError
If ``label`` is invalid.
"""
if np.ndim(label) != 0:
msg = 'label must be a scalar value'
raise TypeError(msg)
return self.get_regions(label, **kwargs)[0]
[docs]
def get_regions(self, labels, **kwargs):
"""
Return a list of `regions
<https://astropy-regions.readthedocs.io>`_ region objects for
the given labels.
The returned polygon regions are defined as the exteriors of the
source segments. Interior holes within the source segments are
not included.
Parameters
----------
labels : int, array_like (1D, int)
The label number(s).
**kwargs : dict, optional
Any keyword arguments accepted by
`regions.RegionVisual`. Common keywords include
``edgecolor``, ``facecolor``, ``color``, ``linewidth``,
and ``linestyle``.
Returns
-------
regions : list of `~regions.PolygonPixelRegion` or `~regions.Regions`
A list of `~regions.PolygonPixelRegion` objects, or
`~regions.Regions` objects for labels with MultiPolygon
segments (e.g., non-contiguous).
Raises
------
ValueError
If any input ``labels`` are invalid.
"""
labels = np.atleast_1d(labels)
self.check_labels(labels)
visual_kwargs = kwargs or None
regions = []
for label in labels:
poly = self._make_polygon(label, self._get_slice(label))
regions.append(_shapely_polygon_to_region(
poly, label=int(label), visual_kwargs=visual_kwargs))
return regions
[docs]
def get_label_mapping(self, other, *, labels=None):
"""
Find the mapping of labels between this segmentation image
and another one defined on the same pixel grid.
For each label in this segmentation image, find the labels in
``other`` that cover any of the same pixels. The typical use
case is comparing segmentation images of the same sources
before and after deblending, where the result maps each
original (parent) label to the deblended (child) labels that
replaced it.
Parameters
----------
other : `SegmentationImage`
The segmentation image to which the labels are mapped. It
must have the same shape as this segmentation image.
labels : int or 1D array_like (int), optional
The label number(s) to map. If `None` (default), all
labels are mapped.
Returns
-------
mapping : dict
A dictionary mapping each label number in this
segmentation image to a sorted 1D array of the label
numbers in ``other`` that share pixels with it.
Notes
-----
The two segmentation images are usually assumed to have
identical non-zero footprints. That assumption is not
checked. If ``other`` contains background (zero) pixels
within a labeled region of this segmentation image, then 0
will be included in the mapped labels for that label.
For segmentation images deblended in the current session, the
:attr:`parent_to_deblended_labels` and
:attr:`deblended_label_to_parent` attributes already provide
the label mapping. This method is the general tool for two
segmentation images without that provenance, e.g., images
read from files. The inverse mapping can be computed with
``other.get_label_mapping(self)``.
Examples
--------
>>> import numpy as np
>>> from photutils.segmentation import SegmentationImage
>>> segm1 = SegmentationImage(np.array([[1, 1, 0, 2],
... [0, 1, 0, 2]]))
>>> segm2 = SegmentationImage(np.array([[1, 3, 0, 2],
... [0, 3, 0, 2]]))
>>> segm1.get_label_mapping(segm2)
{1: array([1, 3]), 2: array([2])}
>>> segm1.get_label_mapping(segm2, labels=1)
{1: array([1, 3])}
"""
if not isinstance(other, SegmentationImage):
msg = ('The other segmentation image must be a '
'SegmentationImage instance')
raise TypeError(msg)
if other.shape != self.shape:
msg = ('The other segmentation image must have the same '
'shape as this one')
raise ValueError(msg)
mask = self._data > 0
parents = self._data[mask].astype(np.int64)
children = other._data[mask].astype(np.int64)
if parents.size == 0:
mapping = {}
else:
# Pack each (parent, child) label pair into a single
# int64 key so that one sorting pass finds all unique
# pairs. This is safe from overflow for label values
# below 2**31.
mult = np.int64(other.max_label) + 1
pairs = np.unique(parents * mult + children)
parents = pairs // mult
children = (pairs % mult).astype(other._data.dtype)
unique_parents, starts = np.unique(parents,
return_index=True)
mapping = dict(zip(unique_parents.tolist(),
np.split(children, starts[1:]),
strict=True))
if labels is not None:
self.check_labels(labels)
mapping = {int(label): mapping[int(label)]
for label in np.atleast_1d(labels)}
return mapping
[docs]
@deprecated_positional_kwargs(since='3.0', until='4.0')
def imshow(self, ax=None, figsize=None, dpi=None, cmap=None, alpha=None):
"""
Display the segmentation image in a matplotlib
`~matplotlib.axes.Axes` instance.
The segmentation image will be displayed with "nearest"
interpolation and with the origin set to "lower".
Parameters
----------
ax : `matplotlib.axes.Axes` or `None`, optional
The matplotlib axes on which to plot. If `None`, then a new
`~matplotlib.axes.Axes` instance will be created.
figsize : 2-tuple of floats or `None`, optional
The figure dimension (width, height) in inches when creating
a new Axes. This keyword is ignored if ``axes`` is input.
dpi : float or `None`, optional
The figure dots per inch when creating a new Axes. This
keyword is ignored if ``axes`` is input.
cmap : `matplotlib.colors.Colormap`, str, or `None`, optional
The `~matplotlib.colors.Colormap` instance or a registered
matplotlib colormap name used to map scalar data to colors.
If `None`, then the colormap defined by the `cmap` attribute
will be used.
alpha : float, array_like, or `None`, optional
The alpha blending value, between 0 (transparent) and 1
(opaque). If alpha is an array, the alpha blending values
are applied pixel by pixel, and alpha must have the same
shape as the segmentation image.
Returns
-------
result : `matplotlib.image.AxesImage`
An image attached to an `matplotlib.axes.Axes`.
Examples
--------
.. plot::
:include-source:
import matplotlib.pyplot as plt
import numpy as np
from photutils.segmentation import SegmentationImage
data = np.array([[1, 1, 0, 0, 4, 4],
[0, 0, 0, 0, 0, 4],
[0, 0, 3, 3, 0, 0],
[7, 0, 0, 0, 0, 5],
[7, 7, 0, 5, 5, 5],
[7, 7, 0, 0, 5, 5]])
segm = SegmentationImage(data)
fig, ax = plt.subplots()
im = segm.imshow(ax=ax)
fig.colorbar(im, ax=ax)
"""
import matplotlib.pyplot as plt
if ax is None:
_, ax = plt.subplots(figsize=figsize, dpi=dpi)
if cmap is None:
cmap = self.cmap
return ax.imshow(self.data, cmap=cmap, interpolation='nearest',
origin='lower', alpha=alpha, vmin=-0.5,
vmax=self.max_label + 0.5)
[docs]
@deprecated_positional_kwargs(since='3.0', until='4.0')
def imshow_map(self, ax=None, figsize=None, dpi=None, cmap=None,
alpha=None, max_labels=25, cbar_labelsize=None):
"""
Display the segmentation image in a matplotlib
`~matplotlib.axes.Axes` instance with a colorbar.
This method is useful for displaying segmentation images
that have a few labels (e.g., from a cutout) that are not
consecutive. It maps the labels to be consecutive integers
starting from 1 before plotting. The plotted image values are
not the label values, but the colorbar tick labels are used to
show the original labels.
The segmentation image will be displayed with "nearest"
interpolation and with the origin set to "lower".
Parameters
----------
ax : `matplotlib.axes.Axes` or `None`, optional
The matplotlib axes on which to plot. If `None`, then a new
`~matplotlib.axes.Axes` instance will be created.
figsize : 2-tuple of floats or `None`, optional
The figure dimension (width, height) in inches when creating
a new Axes. This keyword is ignored if ``axes`` is input.
dpi : float or `None`, optional
The figure dots per inch when creating a new Axes. This
keyword is ignored if ``axes`` is input.
cmap : `matplotlib.colors.Colormap`, str, or `None`, optional
The `~matplotlib.colors.Colormap` instance or a registered
matplotlib colormap name used to map scalar data to colors.
If `None`, then the colormap defined by the `cmap` attribute
will be used.
alpha : float, array_like, or `None`, optional
The alpha blending value, between 0 (transparent) and 1
(opaque). If alpha is an array, the alpha blending values
are applied pixel by pixel, and alpha must have the same
shape as the segmentation image.
max_labels : int, optional
The maximum number of labels to display in the colorbar. If
the number of labels is greater than ``max_labels``, then
the colorbar will not be displayed.
cbar_labelsize : `None` or float, optional
The font size of the colorbar tick labels.
Returns
-------
result : `matplotlib.image.AxesImage`
An image attached to an `matplotlib.axes.Axes`.
cbar_info : tuple or `None`
The colorbar information as a tuple containing the
`~matplotlib.colorbar.Colorbar` instance, a `~numpy.ndarray`
of tick positions, and a `~numpy.ndarray` of tick labels.
`None` is returned if the colorbar was not plotted.
Examples
--------
.. plot::
:include-source:
import matplotlib.pyplot as plt
import numpy as np
from photutils.segmentation import SegmentationImage
data = np.array([[1, 1, 0, 0, 4, 4],
[0, 0, 0, 0, 0, 4],
[0, 0, 3, 3, 0, 0],
[7, 0, 0, 0, 0, 5],
[7, 7, 0, 5, 5, 5],
[7, 7, 0, 0, 5, 5]])
data *= 1000
segm = SegmentationImage(data)
fig, ax = plt.subplots()
im, cbar = segm.imshow_map(ax=ax)
"""
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap
if ax is None:
_, ax = plt.subplots(figsize=figsize, dpi=dpi)
data, idx = np.unique(self.data, return_inverse=True)
idx = idx.reshape(self.data.shape)
vmin = -0.5
vmax = np.max(idx) + 0.5
# Keep the original cmap colors for the labels
if cmap is None:
cmap = ListedColormap(self.cmap.colors[data])
im = ax.imshow(idx, cmap=cmap, interpolation='nearest', origin='lower',
alpha=alpha, vmin=vmin, vmax=vmax)
cbar_info = None
# The unique data values are the colorbar tick labels. 0 is
# included only if background pixels are present.
cbar_labels = data
if len(cbar_labels) <= max_labels:
cbar_ticks = np.arange(len(cbar_labels))
cbar = ax.figure.colorbar(im, ax=ax, ticks=cbar_ticks)
cbar.ax.set_yticklabels(cbar_labels)
if cbar_labelsize is not None:
cbar.ax.yaxis.set_tick_params(labelsize=cbar_labelsize)
cbar_info = (cbar, cbar_ticks, cbar_labels)
else:
msg = ('The colorbar was not plotted because the number of '
f'labels is greater than {max_labels=}.')
warnings.warn(msg, AstropyUserWarning)
return im, cbar_info
[docs]
class Segment:
"""
Class for a single labeled region (segment) within a segmentation
image.
Parameters
----------
segment_data : int `~numpy.ndarray`
A segmentation array where source regions are labeled by
different positive integer values. A value of zero is reserved
for the background.
label : int
The segment label number.
slices : tuple of two slices
A tuple of two slices representing the minimal box that contains
the labeled region.
bbox : `~photutils.aperture.BoundingBox`
The minimal bounding box that contains the labeled region.
area : float
The area of the segment in pixels**2.
polygon : Shapely polygon, optional
The outline of the segment as a `Shapely
<https://shapely.readthedocs.io/en/stable/>`_ polygon.
Notes
-----
Only the minimal bounding-box cutout of the segmentation array is
stored (as a copy), so `Segment` instances do not prevent garbage
collection of the parent array.
"""
def __init__(self, segment_data, label, slices, bbox, area, *,
polygon=None):
self._segment_data_cutout = np.copy(segment_data[slices])
self._segment_data_shape = segment_data.shape
self.label = label
self.slices = slices
self.bbox = bbox
self.area = area
self.polygon = polygon
def __str__(self):
cls_name = f'<{self.__class__.__module__}.{self.__class__.__name__}>'
params = ['label', 'slices', 'area']
cls_info = [(param, getattr(self, param)) for param in params]
fmt = [f'{key}: {val}' for key, val in cls_info]
return f'{cls_name}\n' + '\n'.join(fmt)
def __repr__(self):
return self.__str__()
# Remove in 4.0
def __getattr__(self, name):
return deprecated_getattr(self, name,
_SEGMENT_DEPRECATED_ATTRIBUTES,
since='3.0', until='4.0')
def _repr_svg_(self):
if self.polygon is not None:
return self.polygon._repr_svg_()
return None
def __array__(self):
"""
Array representation of the labeled region (e.g., for
matplotlib).
"""
return self.data
[docs]
@cached_property
def data(self):
"""
A cutout array of the segment using the minimal bounding box,
where pixels outside the labeled region are set to zero (i.e.,
neighboring segments within the rectangular cutout array are not
shown).
"""
cutout = np.copy(self._segment_data_cutout)
cutout[cutout != self.label] = 0
return cutout
[docs]
@cached_property
def data_masked(self):
"""
A `~numpy.ma.MaskedArray` cutout array of the segment using the
minimal bounding box.
The mask is `True` for pixels outside the source segment (i.e.,
neighboring segments within the rectangular cutout array are
masked).
"""
mask = (self._segment_data_cutout != self.label)
return np.ma.masked_array(self._segment_data_cutout, mask=mask)
[docs]
@deprecated_positional_kwargs(since='3.0', until='4.0')
def make_cutout(self, data, masked_array=False):
"""
Create a (masked) cutout array from the input ``data`` using the
minimal bounding box of the segment (labeled region).
If ``masked_array`` is `False` (default), then the returned
cutout array is simply a `~numpy.ndarray`. The returned cutout
is a view (not a copy) of the input ``data``. No pixels are
altered (e.g., set to zero) within the bounding box.
If ``masked_array`` is `True`, then the returned cutout array is
a `~numpy.ma.MaskedArray`, where the mask is `True` for pixels
outside the segment (labeled region). The data part of the
masked array is a view (not a copy) of the input ``data``.
Parameters
----------
data : 2D `~numpy.ndarray`
The data array from which to create the masked cutout array.
``data`` must have the same shape as the segmentation array.
masked_array : bool, optional
If `True` then a `~numpy.ma.MaskedArray` will be created
where the mask is `True` for pixels outside the segment
(labeled region). If `False`, then a `~numpy.ndarray` will
be generated.
Returns
-------
result : 2D `~numpy.ndarray` or `~numpy.ma.MaskedArray`
The cutout array.
"""
if data.shape != self._segment_data_shape:
msg = 'data must have the same shape as the segmentation array'
raise ValueError(msg)
if masked_array:
mask = (self._segment_data_cutout != self.label)
return np.ma.masked_array(data[self.slices], mask=mask)
return data[self.slices]