# -*implants -*-
"""

Functions for creating retinal implants

"""
import numpy as np
import six
import copy
import logging

from pulse2percept import utils
from pulse2percept import implants
from pulse2percept import files

# Rather than trying to import these all over, try once and then remember
# by setting a flag.
try:
    import skimage.io as sio
    import skimage.transform as sit
    import skimage.color as sic
    has_skimage = True
except (ImportError, AttributeError):
    # Might also raise "dict object has no attribute 'io'"
    has_skimage = False

class MonophasicPulse(utils.TimeSeries):

    def __init__(self, ptype, pdur, tsample, delay_dur=0, stim_dur=None):
        """A pulse with a single phase

        Parameters
        ----------
        ptype : {'anodic', 'cathodic'}
            Pulse type. Anodic pulses have positive current amplitude,
            cathodic pulses have negative amplitude.
        pdur : float
            Pulse duration (s).
        tsample : float
            Sampling time step (s).
        delay_dur : float, optional
            Pulse delay (s). Pulse will be zero-padded (prepended) to deliver
            the pulse only after `delay_dur` milliseconds. Default: 0.
        stim_dur : float, optional
            Stimulus duration (ms). Pulse will be zero-padded (appended) to fit
            the stimulus duration. Default: No additional zero padding,
            `stim_dur` is `pdur`+`delay_dur`.
        """
        if tsample <= 0:
            raise ValueError("tsample must be a non-negative float.")

        if stim_dur is None:
            stim_dur = pdur + delay_dur

        # Convert durations to number of samples
        pulse_size = int(np.round(pdur / tsample))
        delay_size = int(np.round(delay_dur / tsample))
        stim_size = int(np.round(stim_dur / tsample))

        if ptype == 'cathodic':
            pulse = -np.ones(pulse_size)
        elif ptype == 'anodic':
            pulse = np.ones(pulse_size)
        else:
            raise ValueError("Acceptable values for `ptype` are 'anodic', "
                             "'cathodic'.")

        pulse = np.concatenate((np.zeros(delay_size), pulse,
                                np.zeros(stim_size)))
        utils.TimeSeries.__init__(self, tsample, pulse[:stim_size])

class BiphasicPulse(utils.TimeSeries):

    def __init__(self, ptype, pdur, tsample, interphase_dur=0):
        """A charge-balanced pulse with a cathodic and anodic phase

        A single biphasic pulse with duration `pdur` per phase,
        separated by `interphase_dur` is returned.

        Parameters
        ----------
        ptype : {'cathodicfirst', 'anodicfirst'}
            A cathodic-first pulse has the negative phase first, whereas an
            anodic-first pulse has the positive phase first.
        pdur : float
            Duration of single (positive or negative) pulse phase in seconds.
        tsample : float
            Sampling time step in seconds.
        interphase_dur : float, optional
            Duration of inter-phase interval (between positive and negative
            pulse) in seconds. Default: 0.
        """
        if tsample <= 0:
            raise ValueError("tsample must be a non-negative float.")

        # Get the two monophasic pulses
        on = MonophasicPulse('anodic', pdur, tsample, 0, pdur)
        off = MonophasicPulse('cathodic', pdur, tsample, 0, pdur)

        # Insert interphase gap if necessary
        gap = np.zeros(int(round(interphase_dur / tsample)))

        # Order the pulses
        if ptype == 'cathodicfirst':
            # has negative current first
            pulse = np.concatenate((off.data, gap), axis=0)
            pulse = np.concatenate((pulse, on.data), axis=0)
        elif ptype == 'anodicfirst':
            pulse = np.concatenate((on.data, gap), axis=0)
            pulse = np.concatenate((pulse, off.data), axis=0)
        else:
            raise ValueError("Acceptable values for `type` are "
                             "'anodicfirst' or 'cathodicfirst'")
        utils.TimeSeries.__init__(self, tsample, pulse)

class PulseTrain(utils.TimeSeries):

    def __init__(self, tsample, freq=20, amp=20, dur=0.5, delay=0,
                 pulse_dur=0.45 / 1000, interphase_dur=0.45 / 1000,
                 pulsetype='cathodicfirst',
                 pulseorder='pulsefirst'):
        """A train of biphasic pulses

        Parameters
        ----------
        tsample : float
            Sampling time step (seconds).
        freq : float, optional, default: 20 Hz
            Frequency of the pulse envelope (Hz).
        amp : float, optional, default: 20 uA
            Max amplitude of the pulse train in micro-amps.
        dur : float, optional, default: 0.5 seconds
            Stimulus duration in seconds.
        delay : float, optional, default: 0
            Delay until stimulus on-set in seconds.
        pulse_dur : float, optional, default: 0.45 ms
            Single-pulse duration in seconds.
        interphase_duration : float, optional, default: 0.45 ms
            Single-pulse interphase duration (the time between the positive
            and negative phase) in seconds.
        pulsetype : str, optional, default: 'cathodicfirst'
            Pulse type {'cathodicfirst' | 'anodicfirst'}, where
            'cathodicfirst' has the negative phase first.
        pulseorder : str, optional, default: 'pulsefirst'
            Pulse order {'gapfirst' | 'pulsefirst'}, where
            'pulsefirst' has the pulse first, followed by the gap.
            'gapfirst' has it the other way round.
        """
        if tsample <= 0:
            raise ValueError("tsample must be a non-negative float.")

        # Stimulus size given by `dur`
        stim_size = int(np.round(float(dur) / tsample))

        # Make sure input is non-trivial, else return all zeros
        if np.isclose(freq, 0) or np.isclose(amp, 0):
            utils.TimeSeries.__init__(self, tsample, np.zeros(stim_size))
            return

        # Envelope size (single pulse + gap) given by `freq`
        # Note that this can be larger than `stim_size`, but we will trim
        # the stimulus to proper length at the very end.
        envelope_size = int(np.round(1.0 / float(freq) / tsample))
        if envelope_size > stim_size:
            debug_s = ("Envelope size (%d) clipped to "
                       "stimulus size (%d) for freq=%f" % (envelope_size,
                                                           stim_size,
                                                           freq))
            logging.getLogger(__name__).debug(debug_s)
            envelope_size = stim_size

        # Delay given by `delay`
        delay_size = int(np.round(float(delay) / tsample))

        if delay_size < 0:
            raise ValueError("Delay cannot be negative.")
        delay = np.zeros(delay_size)

        # Single pulse given by `pulse_dur`
        pulse = amp * BiphasicPulse(pulsetype, pulse_dur, tsample,
                                    interphase_dur).data
        pulse_size = pulse.size
        if pulse_size < 0:
            raise ValueError("Single pulse must fit within 1/freq interval.")

        # Then gap is used to fill up what's left
        gap_size = envelope_size - (delay_size + pulse_size)
        if gap_size < 0:
            logging.error("Envelope (%d) can't fit pulse (%d) + delay (%d)" %
                          (envelope_size, pulse_size, delay_size))
            raise ValueError("Pulse and delay must fit within 1/freq "
                             "interval.")
        gap = np.zeros(gap_size)

        pulse_train = np.array([])
        for j in range(int(np.ceil(dur * freq))):
            if pulseorder == 'pulsefirst':
                pulse_train = np.concatenate((pulse_train, delay, pulse,
                                              gap), axis=0)
            elif pulseorder == 'gapfirst':
                pulse_train = np.concatenate((pulse_train, delay, gap,
                                              pulse), axis=0)
            else:
                raise ValueError("Acceptable values for `pulseorder` are "
                                 "'pulsefirst' or 'gapfirst'")

        # If `freq` is not a nice number, the resulting pulse train might not
        # have the desired length
        if pulse_train.size < stim_size:
            fill_size = stim_size - pulse_train.shape[-1]
            pulse_train = np.concatenate((pulse_train, np.zeros(fill_size)),
                                         axis=0)

        # Trim to correct length (takes care of too long arrays, too)
        pulse_train = pulse_train[:stim_size]

        utils.TimeSeries.__init__(self, tsample, pulse_train)

def image2pulsetrain(img, implant, coding='amplitude', valrange=[0, 50],
                     max_contrast=False, const_val=20, invert=False,
                     tsample=0.005 / 1000, dur=0.5, pulsedur=0.5 / 1000.,
                     interphasedur=0.5 / 1000., pulsetype='cathodicfirst'):
    """Converts an image into a series of pulse trains

    This function creates an input stimulus from an RGB or grayscale image.
    The image is down-sampled to fit the spatial layout of the implant
    (currently supported are ArgusI and ArgusII arrays).
    Requires Scikit-Image.

    Parameters
    ----------
    img : str|array_like
        An input image, either a valid filename (string) or a numpy array
        (row x col x channels).
    implant : p2p.implants.ElectrodeArray
        An ElectrodeArray object that describes the implant.
    coding : {'amplitude', 'frequency'}, optional
        A string describing the coding scheme:
        - 'amplitude': Image intensity is linearly converted to a current
                       amplitude between `valrange[0]` and `valrange[1]`.
                       Frequency is held constant at `const_freq`.
        - 'frequency': Image intensity is linearly converted to a pulse
                       frequency between `valrange[0]` and `valrange[1]`.
                       Amplitude is held constant at `const_amp`.
        Default: 'amplitude'
    valrange : list, optional
        Range of stimulation values to be used (If `coding` is 'amplitude',
        specifies min and max current; if `coding` is 'frequency', specifies
        min and max frequency).
        Default: [0, 50]
    max_contrast : bool, optional
        Flag wether to maximize image contrast (True) or not (False).
        Default: False
    const_val : float, optional
        For frequency coding: The constant amplitude value to be used for all
        pulse trains. For amplitude coding: The constant frequency value to
        be used for all pulse trains.
        Default: 20
    invert : bool, optional
        Flag whether to invert the grayscale values of the image (True) or
        not (False).
        Default: False
    tsample : float, optional
        Sampling time step (seconds). Default: 0.005 / 1000 seconds.
    dur : float, optional
        Stimulus duration (seconds). Default: 0.5 seconds.
    pulsedur : float, optional
        Duration of single (positive or negative) pulse phase in seconds.
    interphasedur : float, optional
        Duration of inter-phase interval (between positive and negative
        pulse) in seconds.
    pulsetype : {'cathodicfirst', 'anodicfirst'}, optional
        A cathodic-first pulse has the negative phase first, whereas an
        anodic-first pulse has the positive phase first.

    Returns
    -------
    pulses : list
        A list of p2p.stimuli.PulseTrain objects, one for each electrode in
        the implant.

    """
    if not has_skimage:
        # We don't want to repeatedly import Scikit-Image. This would (e.g.)
        # unnecessarily slow down `video2pulsetrain`.
        raise ImportError("You do not have scikit-image installed. "
                          "You can install it via $ pip install scikit-image.")

    # Make sure range of values is valid
    assert len(valrange) == 2 and valrange[1] > valrange[0]

    isargus1 = isinstance(implant, implants.ArgusI)
    isargus2 = isinstance(implant, implants.ArgusII)
    isalphaims = isinstance(implant, implants.AlphaIMS)
    if not isargus1 and not isargus2 and not isalphaims:
        raise TypeError("For now, implant must be of type implants.ArgusI or "
                        "implants.ArgusII.")

    if isinstance(img, six.string_types):
        # Load image from filename
        img_orig = sio.imread(img, as_grey=True).astype(np.float32)
        logging.getLogger(__name__).info("Loaded file '%s'." % img)
    else:
        if img.ndim == 2:
            # Grayscale
            img_orig = img.astype(np.float32)
        else:
            # Assume RGB, convert to grayscale
            assert img.shape[-1] == 3
            img_orig = sic.rgb2gray(np.array(img)).astype(np.float32)

    # Make sure all pixels are between 0 and 1
    if img_orig.max() > 1.0:
        img_orig /= 255.0

    # Let Scikit-Image do the resampling: Downscale image to fit array
    # Use mode 'reflect' for np.pad: Pads with the reflection of the vector
    # mirrored on the first and last values of the vector along each axis.
    if isargus1:
        img_stim = sit.resize(img_orig, (4, 4), mode='reflect')
    elif isargus2:
        img_stim = sit.resize(img_orig, (6, 10), mode='reflect')
    elif isalphaims:
        img_stim = sit.resize(img_orig, (37, 37), mode='reflect')

    # If specified, invert the mapping of grayscale values:
    if invert:
        img_stim = 1.0 - img_stim

    # If specified, maximize the contrast in the image:
    if max_contrast:
        img_stim -= img_stim.min()
        if img_stim.max() > 0:
            img_stim /= img_stim.max()

    # With all pixels between 0 and 1, now scale to valrange
    assert np.all(img_stim >= 0.0) and np.all(img_stim <= 1.0)
    img_stim = img_stim * np.diff(valrange) + valrange[0]
    assert np.all(img_stim >= valrange[0]) and np.all(img_stim <= valrange[1])

    stim = []
    for _, px in np.ndenumerate(img_stim):
        if coding == 'amplitude':
            amp = px
            freq = const_val
        elif coding == 'frequency':
            amp = const_val
            freq = px
        else:
            e_s = "Acceptable values for `coding` are 'amplitude' or"
            e_s += "'frequency'."
            raise ValueError(e_s)

        pt = PulseTrain(tsample, freq=freq, amp=amp, dur=dur,
                        pulse_dur=pulsedur,
                        interphase_dur=interphasedur,
                        pulsetype=pulsetype)
        stim.append(pt)

    return stim

def video2pulsetrain(filename, implant, framerate=20,
                     coding='amplitude', valrange=[0, 50],
                     max_contrast=False, const_val=20, invert=False,
                     tsample=0.005 / 1000, pulsedur=0.5 / 1000.,
                     interphasedur=0.5 / 1000., pulsetype='cathodicfirst',
                     ffmpeg_path=None, libav_path=None):
    """Converts a video into a series of pulse trains

    This function creates an input stimulus from a video.
    Every frame of the video is passed to `image2pulsetrain`, where it is
    down-sampled to fit the spatial layout of the implant (currently supported
    are ArgusI and ArgusII arrays).
    In this mapping, rows of the image correspond to rows in the implant
    (top row, Argus I: A1 B1 C1 D1, Argus II: A1 A2 ... A10).

    Requires Scikit-Image and Scikit-Video.

    Parameters
    ----------
    img : str|array_like
        An input image, either a valid filename (string) or a numpy array
        (row x col x channels).
    implant : p2p.implants.ElectrodeArray
        An ElectrodeArray object that describes the implant.
    coding : {'amplitude', 'frequency'}, optional
        A string describing the coding scheme:
        - 'amplitude': Image intensity is linearly converted to a current
                       amplitude between `valrange[0]` and `valrange[1]`.
                       Frequency is held constant at `const_freq`.
        - 'frequency': Image intensity is linearly converted to a pulse
                       frequency between `valrange[0]` and `valrange[1]`.
                       Amplitude is held constant at `const_amp`.
        Default: 'amplitude'
    valrange : list, optional
        Range of stimulation values to be used (If `coding` is 'amplitude',
        specifies min and max current; if `coding` is 'frequency', specifies
        min and max frequency).
        Default: [0, 50]
    max_contrast : bool, optional
        Flag wether to maximize image contrast (True) or not (False).
        Default: False
    const_val : float, optional
        For frequency coding: The constant amplitude value to be used for all
        pulse trains. For amplitude coding: The constant frequency value to
        be used for all pulse trains.
        Default: 20
    invert : bool, optional
        Flag whether to invert the grayscale values of the image (True) or
        not (False).
        Default: False
    tsample : float, optional
        Sampling time step (seconds). Default: 0.005 / 1000 seconds.
    dur : float, optional
        Stimulus duration (seconds). Default: 0.5 seconds.
    pulsedur : float, optional
        Duration of single (positive or negative) pulse phase in seconds.
    interphasedur : float, optional
        Duration of inter-phase interval (between positive and negative
        pulse) in seconds.
    pulsetype : {'cathodicfirst', 'anodicfirst'}, optional
        A cathodic-first pulse has the negative phase first, whereas an
        anodic-first pulse has the positive phase first.

    Returns
    -------
    pulses : list
        A list of p2p.stimuli.PulseTrain objects, one for each electrode in
        the implant.

    """

    # Load generator to read video frame-by-frame
    reader = files.load_video_generator(filename, ffmpeg_path, libav_path)

    # Temporarily increase logger level to suppress info messages
    current_level = logging.getLogger(__name__).getEffectiveLevel()
    logging.getLogger(__name__).setLevel(logging.WARN)

    # Convert the desired framerate to a duration (seconds)
    dur = 1.0 / framerate

    # Read one frame at a time, and append to previous frames
    video = []
    for img in reader.nextFrame():
        frame = image2pulsetrain(img, implant, coding=coding,
                                 valrange=valrange, max_contrast=max_contrast,
                                 const_val=const_val, invert=invert,
                                 tsample=tsample, dur=dur, pulsedur=pulsedur,
                                 interphasedur=interphasedur,
                                 pulsetype=pulsetype)
        if video:
            # List of pulse trains: Append new frame to each element
            [v.append(f) for v, f in zip(video, frame)]
        else:
            # Initialize with a list of pulse trains
            video = frame

    # Restore logger level
    logging.getLogger(__name__).setLevel(current_level)

    return video

def parse_pulse_trains(stim, implant):
    """Parse input stimulus and convert to list of pulse trains

    Parameters
    ----------
    stim : utils.TimeSeries|list|dict
        There are several ways to specify an input stimulus:

        - For a single-electrode array, pass a single pulse train; i.e., a
          single utils.TimeSeries object.
        - For a multi-electrode array, pass a list of pulse trains, where
          every pulse train is a utils.TimeSeries object; i.e., one pulse
          train per electrode.
        - For a multi-electrode array, specify all electrodes that should
          receive non-zero pulse trains by name in a dictionary. The key
          of each element is the electrode name, the value is a pulse train.
          Example: stim = {'E1': pt, 'stim': pt}, where 'E1' and 'stim' are
          electrode names, and `pt` is a utils.TimeSeries object.
    implant : p2p.implants.ElectrodeArray
        A p2p.implants.ElectrodeArray object that describes the implant.

    Returns
    -------
    A list of pulse trains; one pulse train per electrode.
    """
    # Parse input stimulus
    if isinstance(stim, utils.TimeSeries):
        # `stim` is a single object: This is only allowed if the implant
        # has only one electrode
        if implant.num_electrodes > 1:
            e_s = "More than 1 electrode given, use a list of pulse trains"
            raise ValueError(e_s)
        pt = [copy.deepcopy(stim)]
    elif isinstance(stim, dict):
        # `stim` is a dictionary: Look up electrode names and assign pulse
        # trains, fill the rest with zeros

        # Get right size from first dict element, then generate all zeros
        idx0 = list(stim.keys())[0]
        pt_zero = utils.TimeSeries(stim[idx0].tsample,
                                   np.zeros_like(stim[idx0].data))
        pt = [pt_zero] * implant.num_electrodes

        # Iterate over dictionary and assign non-zero pulse trains to
        # corresponding electrodes
        for key, value in stim.items():
            el_idx = implant.get_index(key)
            if el_idx is not None:
                pt[el_idx] = copy.deepcopy(value)
            else:
                e_s = "Could not find electrode with name '%s'" % key
                raise ValueError(e_s)
    else:
        # Else, `stim` must be a list of pulse trains, one for each electrode
        if len(stim) != implant.num_electrodes:
            e_s = "Number of pulse trains must match number of electrodes"
            raise ValueError(e_s)
        pt = copy.deepcopy(stim)

    return pt


# Licensed under a 3-clause BSD style license - see LICENSE.rst
from __future__ import (absolute_import, division, print_function,
                        unicode_literals)
import numpy as np
from astropy.table import Table
from astropy.utils import lazyproperty
from astropy.convolution import Kernel2D
import astropy.units as u
from astropy.wcs.utils import pixel_to_skycoord
from .utils.prepare_data import _prepare_data

__all__ = ['SegmentProperties', 'segment_properties', 'properties_table',
           'relabel_sequential', 'relabel_segments', 'remove_segments',
           'remove_border_segments', 'remove_masked_segments']

__doctest_requires__ = {('segment_properties', 'properties_table'): ['scipy'],
                        ('segment_properties', 'properties_table'):
                        ['skimage']}

class SegmentProperties(object):
    """
    Class to calculate photometry and morphological properties of source
    segments.
    """

    def __init__(self, data, segment_image, label, label_slice=None,
                 error=None, effective_gain=None, mask=None, background=None,
                 wcs=None, filtered_data=None, data_prepared=False):
        """
        Parameters
        ----------
        data : array_like or `~astropy.units.Quantity`
            The 2D array from which to calculate the source photometry
            and properties (only if ``filtered_data`` is not input).
            ``data`` should be background-subtracted.

        segment_image : array_like (int)
            A 2D segmentation image, with the same shape as ``data``,
            where sources are labeled by different positive integer
            values.  A value of zero is reserved for the background.

        label : int
            The label number of a source segment in ``segment_image``
            for which to calculate properties.

        label_slice : 2-tuple of slice objects, optional
            A ``(y_slice, x_slice)`` tuple of slice objects defining the
            minimal box enclosing the source segment.  If `None` (the
            default), then ``label_slice`` will be calculated.

        error : array_like or `~astropy.units.Quantity`, optional
            The pixel-wise Gaussian 1-sigma errors of the input
            ``data``.  If ``effective_gain`` is input, then ``error``
            should include all sources of "background" error but
            *exclude* the Poisson error of the sources.  If
            ``effective_gain`` is `None`, then the ``error_image`` is
            assumed to include *all* sources of error, including the
            Poisson error of the sources.  ``error`` must have the same
            shape as ``data``.  See the Notes section below for details
            on the error propagation.

        effective_gain : float, array-like, or `~astropy.units.Quantity`, optional
            Ratio of counts (e.g., electrons or photons) to the units of
            ``data`` used to calculate the Poisson error of the sources.
            If ``effective_gain`` is `None`, then the ``error`` is
            assumed to include *all* sources of error.  See the Notes
            section below for details on the error propagation.

        mask : array_like (bool), optional
            A boolean mask, with the same shape as ``data``, where a
            `True` value indicates the corresponding element of ``data``
            is masked.  Masked data are excluded from all calculations.

        background : float, array_like, or `~astropy.units.Quantity`, optional
            The background level that was previously present in the
            input ``data``.  ``background`` may either be a scalar value
            or a 2D image with the same shape as the input ``data``.
            Inputting the ``background`` merely allows for its
            properties to be measured within each source segment.  The
            input ``background`` does *not* get subtracted from the
            input ``data``, which should already be
            background-subtracted.

        wcs : `~astropy.wcs.WCS`
            The WCS transformation to use.  If `None`, then
            `icrs_centroid`, `ra_icrs_centroid`, and `dec_icrs_centroid`
            will be `None`.

        filtered_data : array-like or `~astropy.units.Quantity`, optional
            The filtered version of the (background-subtracted) ``data``
            from which to calculate the source centroid and
            morphological properties.  The kernel used to perform the
            filtering should be the same one used in defining the source
            segments (e.g., see :func:`~photutils.detect_sources`).  If
            `None`, then the unfiltered ``data`` will be used instead.
            Note that `SExtractor`_'s centroid and morphological
            parameters are calculated from the filtered "detection"
            image.

        data_prepared : bool, optional
            If `True`, then ``error`` is assumed to represent the total
            (background and source) error (``effective_gain`` will be
            ignored) and ``background`` is assumed to be a 2D image.
            This dramatically improves speed if you are calculating the
            properties of many segments from the same data.

        Notes
        -----
        `SExtractor`_'s centroid and morphological parameters are always
        calculated from the filtered "detection" image.  The usual
        downside of the filtering is the sources will be made more
        circular than they actually are.  If you wish to reproduce
        `SExtractor`_ results, then use the ``filtered_data`` input.  If
        ``filtered_data`` is `None`, then the unfiltered ``data`` will
        be used for the source centroid and morphological parameters.

        Negative (background-subtracted) data values within the source
        segment are set to zero when measuring morphological properties
        based on image moments.  This could occur, for example, if the
        segmentation image was defined from a different image (e.g.,
        different bandpass) or if the subtracted background was
        incorrectly too high.  `segment_sum` is not affected by negative
        (background-subtracted) data values.  `segment_sum_err` is
        affected only if ``effective_gain`` is used (see below).

        If ``effective_gain`` is input, then ``error`` should include
        all sources of "background" error but *exclude* the Poisson
        error of the sources.  The total error image,
        :math:`\sigma_{\mathrm{tot}}` is then:

        .. math:: \\sigma_{\\mathrm{tot}} = \\sqrt{\\sigma_{\\mathrm{b}}^2 +
                      \\frac{(I - B)}{g}}

        where :math:`\sigma_b`, :math:`(I - B)`, and :math:`g` are the
        background ``error`` image, the background-subtracted ``data``
        image, and ``effective_gain``, respectively.

        Pixels where :math:`(I_i - B_i)` is negative do not contribute
        additional Poisson noise to the total error, i.e.
        :math:`\sigma_{\mathrm{tot}, i} = \sigma_{\mathrm{b}, i}`.  Note
        that this is different from `SExtractor`_, which sums the total
        variance in the segment, including pixels where :math:`(I_i -
        B_i)` is negative.  In such cases, `SExtractor`_ underestimates
        the total errors.

        If ``effective_gain`` is `None`, then ``error`` is assumed to
        include *all* sources of error, including the Poisson error of
        the sources, i.e. :math:`\sigma_{\mathrm{tot}} =
        \mathrm{error}`.

        For example, if your input ``data`` are in units of ADU, then
        ``effective_gain`` should represent electrons/ADU.  If your
        input ``data`` are in units of electrons/s then
        ``effective_gain`` should be the exposure time or an exposure
        time map (e.g., for mosaics with non-uniform exposure times).

        ``effective_gain`` can be a 2D gain image with the same shape as
        the ``data``.  This is useful with mosaic images that have
        variable depths (i.e., exposure times) across the field.  For
        example, one should use an exposure-time map as the
        ``effective_gain`` for a variable depth mosaic image in
        count-rate units.

        `~photutils.SegmentProperties.segment_sum_err` is simply the
        quadrature sum of the pixel-wise total errors over the
        non-masked pixels within the source segment:

        .. math:: \\Delta F = \\sqrt{\\sum_{i \\in S}
                  \\sigma_{\\mathrm{tot}, i}^2}

        where :math:`\Delta F` is
        `~photutils.SegmentProperties.segment_sum_err` and :math:`S` are
        the non-masked pixels in the source segment.

        Custom errors for source segments can be calculated using the
        `~photutils.SegmentProperties.error_cutout_ma` and
        `~photutils.SegmentProperties.background_cutout_ma` properties,
        which are 2D `~numpy.ma.MaskedArray` cutout versions of the
        input ``error`` and ``background``.  The mask is `True` for both
        pixels outside of the source segment and masked pixels.

        .. _SExtractor: http://www.astromatic.net/software/sextractor
        """

        from scipy import ndimage

        if segment_image.shape != data.shape:
            raise ValueError('segment_image and data must have the same '
                             'shape')
        if mask is not None:
            if mask.shape != data.shape:
                raise ValueError('mask and data must have the same shape')

        if label == 0:
            raise ValueError('label "0" is reserved for the background')
        elif label < 0:
            raise ValueError('label must be a positive integer')

        self._segment_image = segment_image
        if not data_prepared:
            data, error, background = _prepare_data(
                data, error=error, effective_gain=effective_gain,
                background=background)

        self._data = data    # background subtracted
        if filtered_data is None:
            self._filtered_data = data    # background subtracted
        else:
            self._filtered_data = filtered_data    # bkgrd sub, then filtered
        self._error = error    # total error from _prepare_data
        self._background = background    # 2D error array from _prepare_data
        self._mask = mask
        self._wcs = wcs

        self.label = label
        if label_slice is not None:
            self._slice = label_slice
        else:
            label_slices = ndimage.find_objects(segment_image)
            self._slice = label_slices[label - 1]
            if self._slice is None:
                raise ValueError('label "{0}" is not in the input '
                                 'segment_image'.format(label))

    def __getitem__(self, key):
        return getattr(self, key, None)

    def make_cutout(self, data, masked_array=False):
        """
        Create a (masked) cutout array from the input ``data`` using the
        minimal bounding box of the source segment.

        Parameters
        ----------
        data : array-like (2D)
            The data array from which to create the masked cutout array.
            ``data`` must have the same shape as the data input into
            `SegmentProperties`.

        masked_array : bool, optional
            If `True` then a `~numpy.ma.MaskedArray` will be created
            where the mask is `True` for both pixels outside of the
            source segment and any masked pixels.  If `False`, then a
            `~numpy.ndarray` will be generated.

        Returns
        -------
        result : `~numpy.ndarray` or `~numpy.ma.MaskedArray` (2D)
            The 2D cutout array or masked array.
        """

        if data is not None:
            data = np.asarray(data)
            if data.shape != self._data.shape:
                raise ValueError('data must have the same shape as the '
                                 'segment image input to SegmentProperties')
            if masked_array:
                return np.ma.masked_array(data[self._slice],
                                          mask=self._local_mask)
            else:
                return data[self._slice]
        else:
            return None

    def to_table(self, columns=None, exclude_columns=None):
        """
        Create a `~astropy.table.Table` of properties.

        If ``columns`` or ``exclude_columns`` are not input, then the
        `~astropy.table.Table` will include all scalar-valued
        properties.  Multi-dimensional properties, e.g.
        `~photutils.SegmentProperties.data_cutout`, can be included in
        the ``columns`` input.

        Parameters
        ----------
        columns : str or list of str, optional
            Names of columns, in order, to include in the output
            `~astropy.table.Table`.  The allowed column names are any of
            the attributes of `SegmentProperties`.

        exclude_columns : str or list of str, optional
            Names of columns to exclude from the default properties list
            in the output `~astropy.table.Table`.  The default
            properties are those with scalar values.

        Returns
        -------
        table : `~astropy.table.Table`
            A single-row table of properties of the segmented source.
        """
        return properties_table(self, columns=columns,
                                exclude_columns=exclude_columns)

    @lazyproperty
    def _in_segment(self):
        """
        _in_segment is `True` for pixels in the labeled source segment.
        """
        return self._segment_image[self._slice] == self.label

    @lazyproperty
    def _local_mask(self):
        """
        _local_mask is `True` for regions outside of the labeled source
        segment or where the input mask is `True`.
        """
        if self._mask is None:
            return ~self._in_segment
        else:
            return np.logical_or(~self._in_segment, self._mask[self._slice])

    @lazyproperty
    def data_cutout(self):
        """
        A 2D cutout from the (background-subtracted) data of the source
        segment.
        """
        return self.make_cutout(self._data, masked_array=False)

    @lazyproperty
    def data_cutout_ma(self):
        """
        A 2D `~numpy.ma.MaskedArray` cutout from the
        (background-subtracted) data, where the mask is `True` for both
        pixels outside of the source segment and masked pixels.
        """
        return self.make_cutout(self._data, masked_array=True)

    @lazyproperty
    def _data_cutout_maskzeroed_double(self):
        """
        A 2D cutout from the (background-subtracted, filtered) data,
        where pixels outside of the source segment and masked pixels are
        set to zero.  Negative data values are also set to zero because
        negative pixels (especially at large radii) can result in image
        moments that result in negative variances.  The cutout image is
        double precision, which is required for scikit-image's Cython
        moment functions.
        """
        cutout = self.make_cutout(self._filtered_data, masked_array=False)
        cutout = np.where(cutout > 0, cutout, 0.)    # negative pixels -> 0
        return (cutout * ~self._local_mask).astype(np.float64)

    @lazyproperty
    def error_cutout_ma(self):
        """
        A 2D `~numpy.ma.MaskedArray` cutout from the input ``error``
        image, where the mask is `True` for both pixels outside of the
        source segment and masked pixels.  If ``error`` is `None`, then
        ``error_cutout_ma`` is also `None`.
        """
        return self.make_cutout(self._error, masked_array=True)

    @lazyproperty
    def background_cutout_ma(self):
        """
        A 2D `~numpy.ma.MaskedArray` cutout from the input
        ``background``, where the mask is `True` for both pixels outside
        of the source segment and masked pixels.  If ``background`` is
        `None`, then ``background_cutout_ma`` is also `None`.
        """
        return self.make_cutout(self._background, masked_array=True)

    @lazyproperty
    def coords(self):
        """
        A tuple of `~numpy.ndarray`\s containing the ``y`` (first array)
        and ``x`` (second array) pixels coordinates of the source
        segment.  Masked pixels are not included.
        """
        yy, xx = np.nonzero(self.data_cutout_ma)
        coords = (yy + self._slice[0].start, xx + self._slice[1].start)
        return coords

    @lazyproperty
    def values(self):
        """
        A `~numpy.ndarray` of the (background-subtracted) pixel values
        within the source segment.  Masked pixels are not included.
        """
        return self.data_cutout[~self._local_mask]

    @lazyproperty
    def moments(self):
        """Spatial moments up to 3rd order of the source segment."""
        from skimage.measure import moments
        return moments(self._data_cutout_maskzeroed_double, 3)

    @lazyproperty
    def moments_central(self):
        """
        Central moments (translation invariant) of the source segment up
        to 3rd order.
        """
        from skimage.measure import moments_central
        ycentroid, xcentroid = self.local_centroid.value
        return moments_central(self._data_cutout_maskzeroed_double,
                               ycentroid, xcentroid, 3)

    @lazyproperty
    def id(self):
        """
        The source identification number corresponding to the object
        label in the ``segment_image``.
        """
        return self.label

    @lazyproperty
    def local_centroid(self):
        """
        The ``(y, x)`` coordinate, relative to the `data_cutout`, of
        the centroid within the source segment.
        """
        m = self.moments
        if m[0, 0] != 0:
            ycentroid = m[0, 1] / m[0, 0]
            xcentroid = m[1, 0] / m[0, 0]
            return (ycentroid, xcentroid) * u.pix
        else:
            return (np.nan, np.nan) * u.pix

    @lazyproperty
    def centroid(self):
        """
        The ``(y, x)`` coordinate of the centroid within the source
        segment.
        """
        ycen, xcen = self.local_centroid.value
        return (ycen + self._slice[0].start,
                xcen + self._slice[1].start) * u.pix

    @lazyproperty
    def xcentroid(self):
        """
        The ``x`` coordinate of the centroid within the source segment.
        """
        return self.centroid[1]

    @lazyproperty
    def ycentroid(self):
        """
        The ``y`` coordinate of the centroid within the source segment.
        """
        return self.centroid[0]

    @lazyproperty
    def icrs_centroid(self):
        """
        The ICRS coordinates of the centroid within the source segment,
        returned as a `~astropy.coordinates.SkyCoord` object.
        """
        if self._wcs is not None:
            return pixel_to_skycoord(self.xcentroid.value,
                                     self.ycentroid.value,
                                     self._wcs, origin=1).icrs
        else:
            return None

    @lazyproperty
    def ra_icrs_centroid(self):
        """
        The ICRS Right Ascension coordinate (in degrees) of the centroid
        within the source segment.
        """
        if self._wcs is not None:
            return self.icrs_centroid.ra.degree * u.deg
        else:
            return None

    @lazyproperty
    def dec_icrs_centroid(self):
        """
        The ICRS Declination coordinate (in degrees) of the centroid
        within the source segment.
        """
        if self._wcs is not None:
            return self.icrs_centroid.dec.degree * u.deg
        else:
            return None

    @lazyproperty
    def bbox(self):
        """
        The bounding box ``(ymin, xmin, ymax, xmax)`` of the minimal
        rectangular region containing the source segment.
        """
        # (stop - 1) to return the max pixel location, not the slice index
        return (self._slice[0].start, self._slice[1].start,
                self._slice[0].stop - 1, self._slice[1].stop - 1) * u.pix

    @lazyproperty
    def xmin(self):
        """
        The left ``x`` pixel location of the minimal bounding box
        (`~photutils.SegmentProperties.bbox`) of the source segment.
        """
        return self.bbox[1]

    @lazyproperty
    def xmax(self):
        """
        The right ``x`` pixel location of the minimal bounding box
        (`~photutils.SegmentProperties.bbox`) of the source segment.
        """
        return self.bbox[3]

    @lazyproperty
    def ymin(self):
        """
        The bottom ``y`` pixel location of the minimal bounding box
        (`~photutils.SegmentProperties.bbox`) of the source segment.
        """
        return self.bbox[0]

    @lazyproperty
    def ymax(self):
        """
        The top ``y`` pixel location of the minimal bounding box
        (`~photutils.SegmentProperties.bbox`) of the source segment.
        """
        return self.bbox[2]

    @lazyproperty
    def min_value(self):
        """
        The minimum pixel value of the (background-subtracted) data
        within the source segment.
        """
        return np.min(self.values)

    @lazyproperty
    def max_value(self):
        """
        The maximum pixel value of the (background-subtracted) data
        within the source segment.
        """
        return np.max(self.values)

    @lazyproperty
    def minval_local_pos(self):
        """
        The ``(y, x)`` coordinate, relative to the `data_cutout`, of the
        minimum pixel value of the (background-subtracted) data.
        """
        return np.argwhere(self.data_cutout_ma == self.min_value)[0] * u.pix

    @lazyproperty
    def maxval_local_pos(self):
        """
        The ``(y, x)`` coordinate, relative to the `data_cutout`, of the
        maximum pixel value of the (background-subtracted) data.
        """
        return np.argwhere(self.data_cutout_ma == self.max_value)[0] * u.pix

    @lazyproperty
    def minval_pos(self):
        """
        The ``(y, x)`` coordinate of the minimum pixel value of the
        (background-subtracted) data.
        """
        yp, xp = np.array(self.minval_local_pos)
        return (yp + self._slice[0].start, xp + self._slice[1].start) * u.pix

    @lazyproperty
    def maxval_pos(self):
        """
        The ``(y, x)`` coordinate of the maximum pixel value of the
        (background-subtracted) data.
        """
        yp, xp = np.array(self.maxval_local_pos)
        return (yp + self._slice[0].start, xp + self._slice[1].start) * u.pix

    @lazyproperty
    def minval_xpos(self):
        """
        The ``x`` coordinate of the minimum pixel value of the
        (background-subtracted) data.
        """
        return self.minval_pos[1]

    @lazyproperty
    def minval_ypos(self):
        """
        The ``y`` coordinate of the minimum pixel value of the
        (background-subtracted) data.
        """
        return self.minval_pos[0]

    @lazyproperty
    def maxval_xpos(self):
        """
        The ``x`` coordinate of the maximum pixel value of the
        (background-subtracted) data.
        """
        return self.maxval_pos[1]

    @lazyproperty
    def maxval_ypos(self):
        """
        The ``y`` coordinate of the maximum pixel value of the
        (background-subtracted) data.
        """
        return self.maxval_pos[0]

    @lazyproperty
    def area(self):
        """The area of the source segment in units of pixels**2."""
        return len(self.values) * u.pix**2

    @lazyproperty
    def equivalent_radius(self):
        """
        The radius of a circle with the same `area` as the source
        segment.
        """
        return np.sqrt(self.area / np.pi)

    @lazyproperty
    def perimeter(self):
        """
        The perimeter of the source segment, approximated using a line
        through the centers of the border pixels using a 4-connectivity.
        """
        from skimage.measure import perimeter
        return perimeter(self._in_segment, 4) * u.pix

    @lazyproperty
    def inertia_tensor(self):
        """
        Inertia tensor of the source segment for the rotation around its
        center of mass.
        """
        mu = self.moments_central
        a = mu[2, 0]
        b = -mu[1, 1]
        c = mu[0, 2]
        return np.array([[a, b], [b, c]]) * u.pix**2

    @lazyproperty
    def covariance(self):
        """
        The covariance matrix of the 2D Gaussian function that has the
        same second-order moments as the source segment.
        """
        mu = self.moments_central
        if mu[0, 0] != 0:
            m = mu / mu[0, 0]
            covariance = self._check_covariance(
                np.array([[m[2, 0], m[1, 1]], [m[1, 1], m[0, 2]]]))
            return covariance * u.pix**2
        else:
            return np.empty((2, 2)) * np.nan * u.pix**2

    @staticmethod
    def _check_covariance(covariance):
        """
        Check and modify the covariance matrix in the case of
        "infinitely" thin detections.  This follows SExtractor's
        prescription.
        """
        p = 1. / 12
        val = (covariance[0, 0] * covariance[1, 1]) - covariance[0, 1]**2
        if val >= p**2:
            return covariance
        else:
            covar = np.copy(covariance)
            while val < p**2:
                covar[0, 0] += p
                covar[1, 1] += p
                val = (covar[0, 0] * covar[1, 1]) - covar[0, 1]**2
            return covar

    @lazyproperty
    def covariance_eigvals(self):
        """
        The two eigenvalues of the `covariance` matrix in decreasing
        order.
        """
        if not np.isnan(np.sum(self.covariance)):
            eigvals = np.linalg.eigvals(self.covariance)
            if np.any(eigvals < 0):    # negative variance
                return (np.nan, np.nan) * u.pix**2
            return (np.max(eigvals), np.min(eigvals)) * u.pix**2
        else:
            return (np.nan, np.nan) * u.pix**2

    @lazyproperty
    def semimajor_axis_sigma(self):
        """
        The 1-sigma standard deviation along the semimajor axis of the
        2D Gaussian function that has the same second-order central
        moments as the source segment.
        """
        # this matches SExtractor's A parameter
        return np.sqrt(self.covariance_eigvals[0])

    @lazyproperty
    def semiminor_axis_sigma(self):
        """
        The 1-sigma standard deviation along the semiminor axis of the
        2D Gaussian function that has the same second-order central
        moments as the source segment.
        """
        # this matches SExtractor's B parameter
        return np.sqrt(self.covariance_eigvals[1])

    @lazyproperty
    def eccentricity(self):
        """
        The eccentricity of the 2D Gaussian function that has the same
        second-order moments as the source segment.

        The eccentricity is the fraction of the distance along the
        semimajor axis at which the focus lies.

        .. math:: e = \\sqrt{1 - \\frac{b^2}{a^2}}

        where :math:`a` and :math:`b` are the lengths of the semimajor
        and semiminor axes, respectively.
        """
        l1, l2 = self.covariance_eigvals
        if l1 == 0:
            return 0.
        return np.sqrt(1. - (l2 / l1))

    @lazyproperty
    def orientation(self):
        """
        The angle in radians between the ``x`` axis and the major axis
        of the 2D Gaussian function that has the same second-order
        moments as the source segment.  The angle increases in the
        counter-clockwise direction.
        """
        a, b, b, c = self.covariance.flat
        if a < 0 or c < 0:    # negative variance
            return np.nan * u.rad
        return 0.5 * np.arctan2(2. * b, (a - c))

    @lazyproperty
    def elongation(self):
        """
        `SExtractor`_'s elongation parameter.

        .. math:: \mathrm{elongation} = \\frac{a}{b}

        where :math:`a` and :math:`b` are the lengths of the semimajor
        and semiminor axes, respectively.
        """
        return self.semimajor_axis_sigma / self.semiminor_axis_sigma

    @lazyproperty
    def ellipticity(self):
        """
        `SExtractor`_'s ellipticity parameter.

        .. math:: \mathrm{ellipticity} = 1 - \\frac{b}{a}

        where :math:`a` and :math:`b` are the lengths of the semimajor
        and semiminor axes, respectively.
        """
        return 1.0 - (self.semiminor_axis_sigma / self.semimajor_axis_sigma)

    @lazyproperty
    def covar_sigx2(self):
        """
        The ``(0, 0)`` element of the `covariance` matrix, representing
        :math:`\sigma_x^2`, in units of pixel**2.

        Note that this is the same as `SExtractor`_'s X2 parameter.
        """
        return self.covariance[0, 0]

    @lazyproperty
    def covar_sigy2(self):
        """
        The ``(1, 1)`` element of the `covariance` matrix, representing
        :math:`\sigma_y^2`, in units of pixel**2.

        Note that this is the same as `SExtractor`_'s Y2 parameter.
        """
        return self.covariance[1, 1]

    @lazyproperty
    def covar_sigxy(self):
        """
        The ``(0, 1)`` and ``(1, 0)`` element of the `covariance`
        matrix, representing :math:`\sigma_x \sigma_y`, in units of
        pixel**2.

        Note that this is the same as `SExtractor`_'s XY parameter.
        """
        return self.covariance[0, 1]

    @lazyproperty
    def cxx(self):
        """
        `SExtractor`_'s CXX ellipse parameter in units of pixel**(-2).

        The ellipse is defined as

            .. math::
                cxx (x - \\bar{x})^2 + cxy (x - \\bar{x}) (y - \\bar{y}) +
                cyy (y - \\bar{y})^2 = R^2

        where :math:`R` is a parameter which scales the ellipse (in
        units of the axes lengths).  `SExtractor`_ reports that the
        isophotal limit of a source is well represented by :math:`R
        \\approx 3`.
        """
        return ((np.cos(self.orientation) / self.semimajor_axis_sigma)**2 +
                (np.sin(self.orientation) / self.semiminor_axis_sigma)**2)

    @lazyproperty
    def cyy(self):
        """
        `SExtractor`_'s CYY ellipse parameter in units of pixel**(-2).

        The ellipse is defined as

            .. math::
                cxx (x - \\bar{x})^2 + cxy (x - \\bar{x}) (y - \\bar{y}) +
                cyy (y - \\bar{y})^2 = R^2

        where :math:`R` is a parameter which scales the ellipse (in
        units of the axes lengths).  `SExtractor`_ reports that the
        isophotal limit of a source is well represented by :math:`R
        \\approx 3`.
        """
        return ((np.sin(self.orientation) / self.semimajor_axis_sigma)**2 +
                (np.cos(self.orientation) / self.semiminor_axis_sigma)**2)

    @lazyproperty
    def cxy(self):
        """
        `SExtractor`_'s CXY ellipse parameter in units of pixel**(-2).

        The ellipse is defined as

            .. math::
                cxx (x - \\bar{x})^2 + cxy (x - \\bar{x}) (y - \\bar{y}) +
                cyy (y - \\bar{y})^2 = R^2

        where :math:`R` is a parameter which scales the ellipse (in
        units of the axes lengths).  `SExtractor`_ reports that the
        isophotal limit of a source is well represented by :math:`R
        \\approx 3`.
        """
        return (2. * np.cos(self.orientation) * np.sin(self.orientation) *
                ((1. / self.semimajor_axis_sigma**2) -
                 (1. / self.semiminor_axis_sigma**2)))

    @lazyproperty
    def segment_sum(self):
        """
        The sum of the non-masked background-subtracted data values
        within the source segment.

        .. math:: F = \\sum_{i \\in S} (I_i - B_i)

        where :math:`F` is ``segment_sum``, :math:`(I_i - B_i)` is the
        background-subtracted input ``data``, and :math:`S` are the
        non-masked pixels in the source segment.
        """
        return np.sum(np.ma.masked_array(self._data[self._slice],
                                         mask=self._local_mask))

    @lazyproperty
    def segment_sum_err(self):
        """
        The uncertainty of `~photutils.SegmentProperties.segment_sum`,
        propagated from the input ``error`` array.

        ``segment_sum_err`` is the quadrature sum of the total errors
        over the non-masked pixels within the source segment:

        .. math:: \\Delta F = \\sqrt{\\sum_{i \\in S}
                  \\sigma_{\\mathrm{tot}, i}^2}

        where :math:`\Delta F` is ``segment_sum_err``,
        :math:`\sigma_{\mathrm{tot, i}}` are the pixel-wise total
        errors, and :math:`S` are the non-masked pixels in the source
        segment.
        """
        if self._error is not None:
            # power doesn't work here, see astropy #2968
            # return np.sqrt(np.sum(self.error_cutout_ma**2))
            return np.sqrt(np.sum(
                np.ma.masked_array(self.error_cutout_ma.data**2,
                                   mask=self.error_cutout_ma.mask)))
        else:
            return None

    @lazyproperty
    def background_sum(self):
        """The sum of ``background`` values within the source segment."""
        if self._background is not None:
            return np.sum(self.background_cutout_ma)
        else:
            return None

    @lazyproperty
    def background_mean(self):
        """The mean of ``background`` values within the source segment."""
        if self._background is not None:
            return np.mean(self.background_cutout_ma)
        else:
            return None

    @lazyproperty
    def background_atcentroid(self):
        """
        The value of the ``background`` at the position of the source
        centroid.
        """
        if self._background is None:
            return None
        else:
            return self._background[int(self.ycentroid.value),
                                    int(self.xcentroid.value)]

def segment_properties(data, segment_image, error=None, effective_gain=None,
                       mask=None, background=None, filter_kernel=None,
                       wcs=None, labels=None):
    """
    Calculate photometry and morphological properties of sources defined
    by a labeled segmentation image.

    Parameters
    ----------
    data : array_like or `~astropy.units.Quantity`
        The 2D array from which to calculate the source photometry and
        properties.  ``data`` should be background-subtracted.

    segment_image : array_like (int)
        A 2D segmentation image, with the same shape as ``data``, where
        sources are labeled by different positive integer values.  A
        value of zero is reserved for the background.

    error : array_like or `~astropy.units.Quantity`, optional
        The pixel-wise Gaussian 1-sigma errors of the input ``data``.
        If ``effective_gain`` is input, then ``error`` should include
        all sources of "background" error but *exclude* the Poisson
        error of the sources.  If ``effective_gain`` is `None`, then the
        ``error_image`` is assumed to include *all* sources of error,
        including the Poisson error of the sources.  ``error`` must have
        the same shape as ``data``.  See the Notes section below for
        details on the error propagation.

    effective_gain : float, array-like, or `~astropy.units.Quantity`, optional
        Ratio of counts (e.g., electrons or photons) to the units of
        ``data`` used to calculate the Poisson error of the sources.  If
        ``effective_gain`` is `None`, then the ``error`` is assumed to
        include *all* sources of error.  See the Notes section below for
        details on the error propagation.

    mask : array_like (bool), optional
        A boolean mask, with the same shape as ``data``, where a `True`
        value indicates the corresponding element of ``data`` is masked.
        Masked data are excluded from all calculations.

    background : float, array_like, or `~astropy.units.Quantity`, optional
        The background level that was previously present in the input
        ``data``.  ``background`` may either be a scalar value or a 2D
        image with the same shape as the input ``data``.  Inputting the
        ``background`` merely allows for its properties to be measured
        within each source segment.  The input ``background`` does *not*
        get subtracted from the input ``data``, which should already be
        background-subtracted.

    filter_kernel : array-like (2D) or `~astropy.convolution.Kernel2D`, optional
        The 2D array of the kernel used to filter the data prior to
        calculating the source centroid and morphological parameters.
        The kernel should be the same one used in defining the source
        segments (e.g., see :func:`~photutils.detect_sources`).  If
        `None`, then the unfiltered ``data`` will be used instead.  Note
        that `SExtractor`_'s centroid and morphological parameters are
        calculated from the filtered "detection" image.

    wcs : `~astropy.wcs.WCS`
        The WCS transformation to use.  If `None`, then the
        ``ra_icrs_centroid`` and ``dec_icrs_centroid`` columns will
        contain `None`\s.

    labels : int or list of ints
        Subset of ``segment_image`` labels for which to calculate the
        properties.  If `None`, then the properties will be calculated
        for all source segments (the default).

    Returns
    -------
    output : list of `SegmentProperties` objects
        A list of `SegmentProperties` objects, one for each source
        segment.  The properties can be accessed as attributes or keys.

    Notes
    -----
    `SExtractor`_'s centroid and morphological parameters are always
    calculated from the filtered "detection" image.  The usual downside
    of the filtering is the sources will be made more circular than they
    actually are.  If you wish to reproduce `SExtractor`_ results, then
    use the ``filtered_data`` input.  If ``filtered_data`` is `None`,
    then the unfiltered ``data`` will be used for the source centroid
    and morphological parameters.

    Negative (background-subtracted) data values within the source
    segment are set to zero when measuring morphological properties
    based on image moments.  This could occur, for example, if the
    segmentation image was defined from a different image (e.g.,
    different bandpass) or if the subtracted background was incorrectly
    too high.  `~photutils.SegmentProperties.segment_sum` is not
    affected by negative (background-subtracted) data values.
    `~photutils.SegmentProperties.segment_sum_err` is affected only if
    ``effective_gain`` is used (see below).

    If ``effective_gain`` is input, then ``error`` should include all
    sources of "background" error but *exclude* the Poisson error of the
    sources.  The total error image, :math:`\sigma_{\mathrm{tot}}` is
    then:

    .. math:: \\sigma_{\\mathrm{tot}} = \\sqrt{\\sigma_{\\mathrm{b}}^2 +
                  \\frac{(I - B)}{g}}

    where :math:`\sigma_b`, :math:`(I - B)`, and :math:`g` are the
    background ``error`` image, the background-subtracted ``data``
    image, and ``effective_gain``, respectively.

    Pixels where :math:`(I_i - B_i)` is negative do not contribute
    additional Poisson noise to the total error, i.e.
    :math:`\sigma_{\mathrm{tot}, i} = \sigma_{\mathrm{b}, i}`.  Note
    that this is different from `SExtractor`_, which sums the total
    variance in the segment, including pixels where :math:`(I_i - B_i)`
    is negative.  In such cases, `SExtractor`_ underestimates the total
    errors.

    If ``effective_gain`` is `None`, then ``error`` is assumed to
    include *all* sources of error, including the Poisson error of the
    sources, i.e. :math:`\sigma_{\mathrm{tot}} = \mathrm{error}`.

    For example, if your input ``data`` are in units of ADU, then
    ``effective_gain`` should represent electrons/ADU.  If your input
    ``data`` are in units of electrons/s then ``effective_gain`` should
    be the exposure time or an exposure time map (e.g., for mosaics with
    non-uniform exposure times).

    ``effective_gain`` can be a 2D gain image with the same shape as the
    ``data``.  This is useful with mosaic images that have variable
    depths (i.e., exposure times) across the field.  For example, one
    should use an exposure-time map as the ``effective_gain`` for a
    variable depth mosaic image in count-rate units.

    `~photutils.SegmentProperties.segment_sum_err` is simply the
    quadrature sum of the pixel-wise total errors over the non-masked
    pixels within the source segment:

    .. math:: \\Delta F = \\sqrt{\\sum_{i \\in S}
              \\sigma_{\\mathrm{tot}, i}^2}

    where :math:`\Delta F` is
    `~photutils.SegmentProperties.segment_sum_err` and :math:`S` are the
    non-masked pixels in the source segment.

    .. _SExtractor: http://www.astromatic.net/software/sextractor

    See Also
    --------
    :class:`photutils.detection.detect_sources`, properties_table

    Examples
    --------
    >>> import numpy as np
    >>> from photutils import segment_properties
    >>> image = np.arange(16.).reshape(4, 4)
    >>> print(image)
    [[  0.   1.   2.   3.]
     [  4.   5.   6.   7.]
     [  8.   9.  10.  11.]
     [ 12.  13.  14.  15.]]
    >>> segm_image = np.array([[1, 1, 0, 0],
    ...                        [1, 0, 0, 2],
    ...                        [0, 0, 2, 2],
    ...                        [0, 2, 2, 0]])
    >>> props = segment_properties(image, segm_image)

    Print some properties of the first object (labeled with ``1`` in the
    segmentation image):

    >>> print(props[0].id)    # id corresponds to segment label number
    1
    >>> print(props[0].centroid)    # doctest: +FLOAT_CMP
    [ 0.8  0.2] pix
    >>> print(props[0].segment_sum)    # doctest: +FLOAT_CMP
    5.0
    >>> print(props[0].area)    # doctest: +FLOAT_CMP
    3.0 pix2
    >>> print(props[0].max_value)    # doctest: +FLOAT_CMP
    4.0

    Print some properties of the second object (labeled with ``2`` in
    the segmentation image):

    >>> print(props[1].id)    # id corresponds to segment label number
    2
    >>> print(props[1].centroid)    # doctest: +FLOAT_CMP
    [ 2.36363636  2.09090909] pix
    >>> print(props[1].perimeter)    # doctest: +FLOAT_CMP
    5.41421356237 pix
    >>> print(props[1].orientation)    # doctest: +FLOAT_CMP
    -0.741759306923 rad
    """

    from scipy import ndimage

    if segment_image.shape != data.shape:
        raise ValueError('segment_image and data must have the same shape')

    if labels is None:
        label_ids = np.unique(segment_image[segment_image > 0])
    else:
        label_ids = np.atleast_1d(labels)

    # prepare the input data once, instead of repeating for each segment
    data, error, background = _prepare_data(
        data, error=error, effective_gain=effective_gain,
        background=background)
    data_prepared = True

    # filter the data once, instead of repeating for each segment
    if filter_kernel is not None:
        conv_mode, conv_val = 'constant', 0.0
        if isinstance(filter_kernel, Kernel2D):
            filtered_data = ndimage.convolve(data, filter_kernel.array,
                                             mode=conv_mode, cval=conv_val)
        else:
            filtered_data = ndimage.convolve(data, filter_kernel,
                                             mode=conv_mode, cval=conv_val)
    else:
        filtered_data = None

    label_slices = ndimage.find_objects(segment_image)
    segm_propslist = []
    for i, label_slice in enumerate(label_slices):
        label = i + 1    # consecutive even if some label numbers are missing
        # label_slice is None for missing label numbers
        if label_slice is None or label not in label_ids:
            continue
        segm_props = SegmentProperties(
            data, segment_image, label, label_slice=label_slice, error=error,
            effective_gain=effective_gain, mask=mask, background=background,
            wcs=wcs, filtered_data=filtered_data, data_prepared=data_prepared)
        segm_propslist.append(segm_props)
    return segm_propslist

def properties_table(segment_props, columns=None, exclude_columns=None):
    """
    Construct a `~astropy.table.Table` of properties from a list of
    `SegmentProperties` objects.

    If ``columns`` or ``exclude_columns`` are not input, then the
    `~astropy.table.Table` will include all scalar-valued properties.
    Multi-dimensional properties, e.g.
    `~photutils.SegmentProperties.data_cutout`, can be included in the
    ``columns`` input.

    Parameters
    ----------
    segment_props : `SegmentProperties` or list of `SegmentProperties`
        A `SegmentProperties` object or list of `SegmentProperties`
        objects, one for each source segment.

    columns : str or list of str, optional
        Names of columns, in order, to include in the output
        `~astropy.table.Table`.  The allowed column names are any of the
        attributes of `SegmentProperties`.

    exclude_columns : str or list of str, optional
        Names of columns to exclude from the default properties list in
        the output `~astropy.table.Table`.  The default properties are
        those with scalar values.

    Returns
    -------
    table : `~astropy.table.Table`
        A table of properties of the segmented sources, one row per
        source segment.

    See Also
    --------
    :class:`photutils.detection.detect_sources`, segment_properties

    Examples
    --------
    >>> import numpy as np
    >>> from photutils import segment_properties, properties_table
    >>> image = np.arange(16.).reshape(4, 4)
    >>> print(image)
    [[  0.   1.   2.   3.]
     [  4.   5.   6.   7.]
     [  8.   9.  10.  11.]
     [ 12.  13.  14.  15.]]
    >>> segm_image = np.array([[1, 1, 0, 0],
    ...                        [1, 0, 0, 2],
    ...                        [0, 0, 2, 2],
    ...                        [0, 2, 2, 0]])
    >>> segm_props = segment_properties(image, segm_image)
    >>> columns = ['id', 'xcentroid', 'ycentroid', 'segment_sum']
    >>> t = properties_table(segm_props, columns=columns)
    >>> print(t)
     id   xcentroid     ycentroid   segment_sum
             pix           pix
    --- ------------- ------------- -----------
      1           0.2           0.8         5.0
      2 2.09090909091 2.36363636364        55.0
    """

    if isinstance(segment_props, list) and len(segment_props) == 0:
        raise ValueError('segment_props is an empty list')
    segment_props = np.atleast_1d(segment_props)

    props_table = Table()
    # all scalar-valued properties
    columns_all = ['id', 'xcentroid', 'ycentroid', 'ra_icrs_centroid',
                   'dec_icrs_centroid', 'segment_sum',
                   'segment_sum_err', 'background_sum', 'background_mean',
                   'background_atcentroid', 'xmin', 'xmax', 'ymin', 'ymax',
                   'min_value', 'max_value', 'minval_xpos', 'minval_ypos',
                   'maxval_xpos', 'maxval_ypos', 'area', 'equivalent_radius',
                   'perimeter', 'semimajor_axis_sigma',
                   'semiminor_axis_sigma', 'eccentricity', 'orientation',
                   'ellipticity', 'elongation', 'covar_sigx2',
                   'covar_sigxy', 'covar_sigy2', 'cxx', 'cxy', 'cyy']

    table_columns = None
    if exclude_columns is not None:
        table_columns = [s for s in columns_all if s not in exclude_columns]

    if columns is not None:
        table_columns = np.atleast_1d(columns)

    if table_columns is None:
        table_columns = columns_all

    # it's *much* faster to calculate world coordinates using the
    # complete list of (x, y) instead of from the individual (x, y).
    # The assumption here is that the wcs is the same for each
    # element of segment_props.
    if ('ra_icrs_centroid' in table_columns or
            'dec_icrs_centroid' in table_columns):
        xcentroid = [props.xcentroid.value for props in segment_props]
        ycentroid = [props.ycentroid.value for props in segment_props]
        if segment_props[0]._wcs is not None:
            skycoord = pixel_to_skycoord(
                xcentroid, ycentroid, segment_props[0]._wcs, origin=1).icrs
            ra = skycoord.ra.degree * u.deg
            dec = skycoord.dec.degree * u.deg
        else:
            nprops = len(segment_props)
            ra, dec = [None] * nprops, [None] * nprops

    for column in table_columns:
        if column == 'ra_icrs_centroid':
            props_table[column] = ra
        elif column == 'dec_icrs_centroid':
            props_table[column] = dec
        else:
            values = [getattr(props, column) for props in segment_props]
            if isinstance(values[0], u.Quantity):
                # turn list of Quantities into a Quantity array
                values = u.Quantity(values)
            props_table[column] = values

    return props_table

def relabel_sequential(segment_image, start_label=1):
    """
    Relabel the labels in a segmentation image sequentially, such that
    there are no missing label numbers.

    Parameters
    ----------
    segment_image : array_like (int)
        A 2D segmentation image where sources are labeled by different
        positive integer values.  A value of zero is reserved for the
        background.

    start_label : int
        The starting label number, which should be strictly positive.
        The default is 1.

    Returns
    -------
    result : `~numpy.ndarray` (int)
        The relabeled segmentation image.

    Examples
    --------
    >>> from photutils.segmentation import relabel_sequential
    >>> segment_image = [[1, 1, 0],
    ...                  [1, 0, 3],
    ...                  [0, 3, 3]]
    >>> relabel_sequential(segment_image)
    array([[1, 1, 0],
           [1, 0, 2],
           [0, 2, 2]])
    """

    if start_label <= 0:
        raise ValueError('start_label must be >= 0.')
    segment_image = np.array(segment_image).astype(np.int)
    label_max = int(np.max(segment_image))
    labels = np.unique(segment_image[segment_image.nonzero()])
    if (label_max == len(labels)) and (labels[0] == start_label):
        return segment_image
    forward_map = np.zeros(label_max + 1, dtype=np.int)
    forward_map[labels] = np.arange(len(labels)) + start_label
    return forward_map[segment_image]

def relabel_segments(segment_image, labels, new_label):
    """
    Relabel the labels in a segmentation image. ``labels`` will be
    relabeled to ``new_label``.

    Parameters
    ----------
    segment_image : array_like (int)
        A 2D segmentation image where sources are labeled by different
        positive integer values.  A value of zero is reserved for the
        background.

    labels : int, array-like (1D, int)
        The label numbers(s) to relabel.

    new_label : int
        The relabeled label number.

    Returns
    -------
    result : `~numpy.ndarray` (int)
        The relabeled segmentation image.

    Examples
    --------
    >>> from photutils.segmentation import relabel_segments
    >>> segment_image = [[1, 1, 0],
    ...                  [0, 0, 3],
    ...                  [2, 0, 3]]
    >>> relabel_segments(segment_image, labels=[1, 3], new_label=5)
    array([[5, 5, 0],
           [0, 0, 5],
           [2, 0, 5]])
    """

    labels = np.atleast_1d(labels)
    segment_image = np.array(segment_image, copy=True).astype(np.int)
    for label in labels:
        segment_image[np.where(segment_image == label)] = new_label
    return segment_image

def remove_segments(segment_image, labels, relabel=False):
    """
    Remove labeled segments from a segmentation image.

    Parameters
    ----------
    segment_image : array_like (int)
        A 2D segmentation image where sources are labeled by different
        positive integer values.  A value of zero is reserved for the
        background.

    labels : int, array-like (1D, int)
        The label number(s) of the segments to remove.  Labels of zero
        and those not in ``segment_image`` will be ignored.

    relabel : bool
        If `True`, the the segmentation image will be relabeled such
        that the labels are in sequential order starting from 1.

    Returns
    -------
    result : `~numpy.ndarray` (int)
        The modified segmentation image.

    Examples
    --------
    >>> from photutils.segmentation import remove_segments
    >>> segment_image = [[1, 1, 0],
    ...                  [0, 0, 3],
    ...                  [2, 0, 3]]
    >>> remove_segments(segment_image, labels=2)
    array([[1, 1, 0],
           [0, 0, 3],
           [0, 0, 3]])
    """

    segment_out = relabel_segments(segment_image, labels, new_label=0)
    if relabel:
        segment_out = relabel_sequential(segment_out)
    return segment_out

def remove_border_segments(segment_image, border_width, partial_overlap=True,
                           relabel=False):
    """
    Remove labeled segments around the border of a segmentation image.

    Parameters
    ----------
    segment_image : array_like (int)
        A 2D segmentation image where sources are labeled by different
        positive integer values.  A value of zero is reserved for the
        background.

    border_width : int
        The width of the border region in pixels.

    partial_overlap : bool, optional
        If this is set to `True` (the default), a segment that partially
        extends into the border region will also be removed.  Segments
        that are completely within the border region are always removed.

    relabel : bool
        If `True`, the the segmentation image will be relabeled such
        that the labels are in sequential order starting from 1.

    Returns
    -------
    result : `~numpy.ndarray` (int)
        The modified segmentation image.

    Examples
    --------
    >>> from photutils.segmentation import remove_border_segments
    >>> segment_image = [[1, 1, 0, 4, 4],
    ...                  [0, 0, 0, 0, 0],
    ...                  [0, 0, 3, 0, 5],
    ...                  [2, 2, 0, 0, 5],
    ...                  [2, 2, 0, 5, 5]]
    >>> remove_border_segments(segment_image, border_width=1)
    array([[0, 0, 0, 0, 0],
           [0, 0, 0, 0, 0],
           [0, 0, 3, 0, 0],
           [0, 0, 0, 0, 0],
           [0, 0, 0, 0, 0]])
    >>> remove_border_segments(segment_image, border_width=1,
    ...                        partial_overlap=False)
    array([[0, 0, 0, 0, 0],
           [0, 0, 0, 0, 0],
           [0, 0, 3, 0, 0],
           [2, 2, 0, 0, 0],
           [2, 2, 0, 0, 0]])
    """

    segment_image = np.array(segment_image).astype(np.int)
    if border_width >= min(segment_image.shape) / 2:
        raise ValueError('border_width must be smaller than half the '
                         'image size in either dimension')
    border = np.zeros_like(segment_image, dtype=np.bool)
    border[:border_width, :] = True
    border[-border_width:, :] = True
    border[:, :border_width] = True
    border[:, -border_width:] = True
    return remove_masked_segments(segment_image, border,
                                  partial_overlap=partial_overlap,
                                  relabel=relabel)

def remove_masked_segments(segment_image, mask, partial_overlap=True,
                           relabel=False):
    """
    Remove labeled segments located within a masked region.

    Parameters
    ----------
    segment_image : array_like (int)
        A 2D segmentation image where sources are labeled by different
        positive integer values.  A value of zero is reserved for the
        background.

    mask : array_like (bool), optional
        A boolean mask, with the same shape as ``segment_image``, where
        a `True` value indicates masked pixels.

    partial_overlap : bool, optional
        If this is set to `True` (the 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
        If `True`, the the segmentation image will be relabeled such
        that the labels are in sequential order starting from 1.

    Returns
    -------
    result : `~numpy.ndarray` (int)
        The modified segmentation image.

    Examples
    --------
    >>> from photutils.segmentation import remove_masked_segments
    >>> segment_image = [[1, 1, 0, 4, 4],
    ...                  [0, 0, 0, 0, 4],
    ...                  [0, 0, 3, 0, 0],
    ...                  [2, 2, 0, 0, 5],
    ...                  [2, 2, 0, 5, 5]]
    >>> mask = np.zeros_like(segment_image, dtype=np.bool)
    >>> mask[0, :] = True
    >>> remove_masked_segments(segment_image, mask)
    array([[0, 0, 0, 0, 0],
           [0, 0, 0, 0, 0],
           [0, 0, 3, 0, 0],
           [2, 2, 0, 0, 5],
           [2, 2, 0, 5, 5]])
    >>> remove_masked_segments(segment_image, mask, partial_overlap=False)
    array([[0, 0, 0, 4, 4],
           [0, 0, 0, 0, 4],
           [0, 0, 3, 0, 0],
           [2, 2, 0, 0, 5],
           [2, 2, 0, 5, 5]])
    """

    segment_image = np.array(segment_image).astype(np.int)
    if segment_image.shape != mask.shape:
        raise ValueError('segment_image and mask must have the same shape')
    labels = np.unique(segment_image[mask])
    if not partial_overlap:
        inside_labels = np.unique(segment_image[~mask])
        labels = [i for i in labels if i not in inside_labels]
    return remove_segments(segment_image, labels, relabel=relabel)

import numpy,from scipy,import astropy,from astropy


#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
silentsub.device
================

A generic device class for multiprimary light stimulators.

@author: jtm
"""

from typing import List, Union, Optional, Tuple, Any

from scipy.interpolate import interp1d
from scipy.optimize import curve_fit, minimize, basinhopping, OptimizeResult
from scipy.stats import beta
import matplotlib.pyplot as plt
import matplotlib.path as mplpath
import seaborn as sns
import pandas as pd
import numpy as np
from colour.plotting import plot_chromaticity_diagram_CIE1931

from pysilsub.CIE import (get_CIES026,
                           get_CIE_1924_photopic_vl,
                           get_CIE170_2_chromaticity_coordinates)
from pysilsub import colorfunc
from pysilsub.plotting import stim_plot

Settings = Union[List[int], List[float]]

class StimulationDevice:
    """Generic class for multiprimary stimultion device."""

    # Class attribute colors for photoreceptors
    photoreceptors = ['S', 'M', 'L', 'R', 'I']

    aopic_colors = {
        'S': 'tab:blue',
        'M': 'tab:green',
        'L': 'tab:red',
        'R': 'tab:grey',
        'I': 'tab:cyan'
    }

    # empty dict for curve fit params
    curveparams = {}

    def __init__(self,
                 resolutions: List[int],
                 colors: List[str],
                 spds: pd.DataFrame,
                 spd_binwidth: Optional[int] = 1) -> None:
        """Instantiate class for multiprimary light stimulation devices.

        Parameters
        ----------
        resolutions : list of int
            Resolution depth of primaries, i.e., the number of steps available
            for specifying the intensity of each primary. This is a list of
            integers to allow for systems where primaries may have different
            resolution depths. The number of elements in the list must
            equal the number of device primaries.
        colors : list of str
            List of valid color names for the primaries. Must be in
            `matplotlib.colors.cnames <https://matplotlib.org/stable/gallery/color/named_colors.html>`_.
        spds : pd.DataFrame
            Spectral measurements to characterise the output of the device.
            Column headers must be wavelengths and each row a spectrum.
            Additional columns are needed to identify the primary/setting. For
            example, 380, ..., 780, primary, setting.
        spd_binwidth : int, optional
            Binwidth of spectral measurements. The default is 1.

        Returns
        -------
        None

        """
        self.resolutions = resolutions
        self.colors = colors
        self.spds = spds
        self.spd_binwidth = spd_binwidth

        # create important data
        self.nprimaries = len(self.resolutions)
        self.wls = self.spds.columns
        self.bounds = [(0., 1.,) for primary in self.resolutions]

    # Starts properly here
    def _get_gamut(self):
        max_spds = self.spds.loc[(slice(None), self.resolutions), :]
        XYZ = max_spds.apply(colorfunc.spd_to_XYZ, axis=1)
        xy = (XYZ[['X', 'Y']].div(XYZ.sum(axis=1), axis=0)
              .rename(columns={'X':'x','Y':'y'}))
        xy = xy.append(xy.iloc[0], ignore_index=True)  # Join the dots
        return xy

    def _xy_in_gamut(self, xy_coord: Tuple[float]):
        """Return True if xy_coord is within the gamut of the device"""
        poly_path = mplpath.Path(self._get_gamut().to_numpy())
        return poly_path.contains_point(xy_coord)

    def plot_gamut(self,
                   ax: plt.Axes = None,
                   show_1931_horseshoe: bool = True,
                   show_CIE170_2_horseshoe: bool = True
                   ) -> Union[plt.Figure, None]:
        """Plot the gamut of the stimulation device.

        Parameters
        ----------
        ax : plt.Axes, optional
            Axes on which to plot. The default is None.
        show_1931_horseshoe : bool, optional
            Whether to show the CIE1931 chromaticity horseshoe. The default is
            True.
        show_CIE170_2_horseshoe : bool, optional
            Whether to show the CIE170_2 chromaticity horseshoe. The default is
            True.

        Returns
        -------
        fig : plt.Figure or None
            The plot.

        """
        gamut = self._get_gamut()
        if ax is None:
            fig, ax = plt.subplots(1, 1, figsize=(6, 6))
        if show_1931_horseshoe:
            plot_chromaticity_diagram_CIE1931(
                axes=ax, title=False, standalone=False)
        if show_CIE170_2_horseshoe:
            cie170_2 = get_CIE170_2_chromaticity_coordinates(connect=True)
            ax.plot(cie170_2['x'], cie170_2['y'],
                    c='k', ls=':', label='CIE 170-2')
        ax.plot(gamut['x'], gamut['y'], color='k',
                lw=2, marker='x', markersize=8, label='Gamut')
        ax.set(xlim=(-.15, .9),
               ylim=(-.1, 1),
               title='Stimulation Device gamut')
        if ax is None:
            return fig
        else:
            return None

    def plot_spds(self) -> plt.Figure:
        """Plot the spectral power distributions for the stimulation device.

        Returns
        -------
        fig : plt.Figure
            The plot.

        """
        data = (self.spds.reset_index()
                    .melt(id_vars=['Primary', 'Setting'],
                          value_name='Flux',
                          var_name='Wavelength (nm)'))

        fig, ax = plt.subplots(figsize=(12, 4))

        _ = sns.lineplot(
            x='Wavelength (nm)', y='Flux', data=data, hue='Primary',
            palette=self.colors, units='Setting', ax=ax, lw=.1, estimator=None
        )
        ax.set_title('Stimulation Device SPDs')
        return fig

    def calculate_aopic_irradiances(self) -> pd.DataFrame:
        """Calculate aopic irradiances from spds.

        Using the CIE026 spectral sensetivities, calculate alphaopic
        irradiances (S, M, L, R, I) for every spectrum in `self.spds`.

        Returns
        -------
        pd.DataFrame
            Alphaopic irradiances.

        """
        sss = get_CIES026(binwidth=self.spd_binwidth, fillna=True)
        return self.spds.dot(sss)

    def calculate_lux(self):
        """Using the CIE1924 photopic luminosity function, calculate lux for
        every spectrum in `self.spds`.

        Returns
        -------
        pd.DataFrame
            Lux values.

        """
        vl = get_CIE_1924_photopic_vl(binwidth=self.spd_binwidth)
        lux = self.spds.dot(vl.values) * 683  # lux conversion factor
        lux.columns = ['lux']
        return lux

    def predict_primary_spd(
            self,
            primary: int,
            setting: Union[int, float],
            name: Union[int, str] = 0) -> np.ndarray:
        """Predict output for a single device primary at a given setting.

        This is the basis for all predictions.

        Parameters
        ----------
        primary : int
            Device primary.
        setting : int or float
            Device primary setting. Must be int (0-max resolution) or float
            (0.-1.).
        name : int or str, optional
            A name for the spectrum. The default is 0.

        Raises
        ------
        ValueError
            If requested value of setting exceeds resolution.

        Returns
        -------
        np.array
            Predicted spd for primary / setting.

        """
        if isinstance(setting, float):
            setting *= self.resolutions[primary]
        if setting > self.resolutions[primary]:
            raise ValueError(f'Requested setting {int(setting)} exceeds '
                             f'resolution of device primary {primary}')
        f = interp1d(x=self.spds.loc[primary].index.values,
                     y=self.spds.loc[primary],
                     axis=0, fill_value='extrapolate')
        return pd.Series(f(setting), name=name, index=self.wls)

    def predict_multiprimary_spd(
            self,
            settings: Union[List[int], List[float]],
            name: Union[int, str] = 0,
            nosum: Optional[bool] = False) -> pd.Series:
        """Predict spectral power distribution of device for given settings.

        Predict the SPD output of the stimulation device for a given list of
        primary settings. Assumes linear summation of primaries.

        Parameters
        ----------
        settings : list of int or list of float
            List of settings for the device primaries. Must be of length
            `self.nprimaries` and consist entirely of float (0.-1.) or int
            (0-max resolution).
        name : int or str, optional
            A name for the spectrum, e.g. 'Background'. The default is 0.
        nosum : bool, optional
            Whether t

        Raises
        ------
        ValueError
            If the number of elements in `settings` is greater than the number
            of device primaries.

            If the elements in `settings` are not exclusively int or float.

        Returns
        -------
        pd.DataFrame if nosum else pd.Series
            Predicted spectra or spectrum for given device settings.

        """
        if len(settings) > self.nprimaries:
            raise ValueError(
                'Number of settings exceeds number of device primaries.'
            )
        if not (all(isinstance(s, int) for s in settings) or
                all(isinstance(s, float) for s in settings)):
            raise ValueError('Can not mix float and int in settings.')
        if name is None:
            name = 0
        spd = []
        for primary, setting in enumerate(settings):
            spd.append(self.predict_primary_spd(primary, setting, primary))
        spd = pd.concat(spd, axis=1)
        if nosum:
            return spd
        else:
            spd = spd.sum(axis=1)
            spd.name = name
            return spd

    def predict_multiprimary_aopic(
            self,
            settings: Union[List[int], List[float]],
            name: Union[int, str] = 0) -> pd.Series:
        """Predict a-opic irradiances of device for given settings.

        Parameters
        ----------
        settings : list of int or list of float
            List of settings for the device primaries. Must be of length
            `self.nprimaries` and consist entirely of float (0.-1.) or int
            (0-max resolution).
        name : int or str, optional
            A name for the output, e.g. 'Background'. The default is 0.

        Returns
        -------
        aopic : pd.DataFrame
            Predicted a-opic irradiances for given device settings.

        """
        spd = self.predict_multiprimary_spd(settings, name=name)
        sss = get_CIES026(binwidth=self.spd_binwidth, fillna=True)
        return spd.dot(sss)

    def find_settings_xyY(
            self,
            xy: Union[List[float], Tuple[float]],
            luminance: float,
            tolerance: Optional[float] = 1e-6,
            plot_solution: Optional[bool] = False,
            verbose: Optional[bool] = True) -> OptimizeResult:
        """Find device settings for a spectrum with requested xyY values.

        Parameters
        ----------
        xy : List[float]
            Requested chromaticity coordinates (xy).
        luminance : float
            Requested luminance.
        tolerance : float, optional
            Acceptable precision for result.
        plot_solution : bool, optional
            Set to True to plot the solution. The default is False.
        verbose : bool, optional
            Set to True to print status messages. The default is False.

        Returns
        -------
        result : OptimizeResult
            The result of the optimisation procedure, with result.x as the
            settings that will produce the spectrum.

        """
        if len(xy) != 2:
            raise ValueError('xy must be of length 2.')

        if not self._xy_in_gamut(xy):
            print("WARNING: specified xy coordinates are outside of")
            print("the device's gamut. Searching for closest match.")
            print("This could take a while, and results may be useless.\n")

        requested_xyY = colorfunc.xy_luminance_to_xyY(xy, luminance)
        requested_LMS = colorfunc.xyY_to_LMS(requested_xyY)

        # Objective function to find device settings for given xyY
        def _xyY_objective_function(x0: List[float]):
            aopic = self.predict_multiprimary_aopic(x0)
            return sum(
                pow(requested_LMS - aopic[['L', 'M', 'S']].to_numpy(), 2)
            )

        # Arguments for local solver
        minimizer_kwargs = {
            'method': 'SLSQP',
            'bounds': self.bounds,
            'options': {'maxiter': 500}
        }

        # Callback for global search
        def _callback(x, f, accepted):
            if accepted and tolerance is not None:
                if f < tolerance:
                    return True

        # Random starting point
        x0 = np.random.uniform(0, 1, self.nprimaries)

        # Do global search
        result = basinhopping(
            func=_xyY_objective_function,
            x0=x0,
            niter=100,
            T=1.0,
            stepsize=0.5,
            minimizer_kwargs=minimizer_kwargs,
            take_step=None,
            accept_test=None,
            callback=_callback,
            interval=50,
            disp=True,
            niter_success=None,
            seed=None,
        )

        # TODO: refactor this
        solution_lms = self.predict_multiprimary_aopic(
            result.x)[['L','M','S']].values
        solution_xyY = colorfunc.LMS_to_xyY(solution_lms)
        print(f'Requested LMS: {requested_LMS}')
        print(f'Solution LMS: {solution_lms}')

        # Reacfactor!
        if plot_solution is not None:
            fig, axs = stim_plot()
            # Plot the spectrum
            self.predict_multiprimary_spd(
                result.x,
                name=f'solution_xyY:\n{solution_xyY.round(3)}').plot(
                    ax=axs[0],
                    legend=True)
            axs[1].scatter(
                x=requested_xyY[0],
                y=requested_xyY[1],
                s=100, marker='o',
                facecolors='none',
                edgecolors='k',
                label='Requested'
                )
            axs[1].scatter(
                x=solution_xyY[0],
                y=solution_xyY[1],
                s=100, c='k',
                marker='x',
                label='Resolved'
                )
            self.plot_gamut(ax=axs[1], show_CIE170_2_horseshoe=False)
            axs[1].legend()
            device_ao = self.predict_multiprimary_aopic(
                result.x, name='Background')
            colors = [val[1] for val in self.aopic_colors.items()]
            device_ao.plot(kind='bar', color=colors, ax=axs[2])

        return result

    # TODO: decide whether to keep these
    def fit_curves(self):
        """Fit curves to the unweighted irradiance of spectral measurements
        and save the parameters.

        Returns
        -------
        fig
            Figure.

        """

        # plot
        ncols = 5
        nrows = int(self.nprimaries / ncols)
        fig, axs = plt.subplots(
            nrows, ncols, figsize=(16, 6), sharex=True, sharey=True)
        axs = [item for sublist in axs for item in sublist]

        for primary in range(self.nprimaries):
            xdata = (self.spds.loc[primary].index
                     / self.resolutions[primary]).to_numpy()
            ydata = self.irradiance.loc[primary].T.to_numpy()[0]
            ydata = ydata / np.max(ydata)

            # Curve fitting function
            def func(x, a, b):
                return beta.cdf(x, a, b)

            axs[primary].scatter(
                xdata, ydata, color=self.colors[primary], s=2)

            # Fit
            popt, pcov = curve_fit(beta.cdf, xdata, ydata, p0=[2.0, 1.0])
            self.curveparams[primary] = popt
            ypred = func(xdata, *popt)
            axs[primary].plot(
                xdata, ypred, color=self.colors[primary],
                label='fit: a=%5.3f, b=%5.3f' % tuple(popt))
            axs[primary].set_title('Primary {}'.format(primary))
            axs[primary].legend()

        for ax in axs:
            ax.set_ylabel('Output fraction (irradiance)')
            ax.set_xlabel('Input fraction')

        plt.tight_layout()

        return fig

    def optimise(
            self,
            primary: int,
            settings: Union[List[int], List[float]]
            ) -> Union[List[int], List[float]]:
        """Optimise a stimulus profile by applying the curve parameters.

        Parameters
        ----------
        primary : int
            Primary being optimised.
        settings : np.array
            Array of intensity values to optimise for specified LED.

        Returns
        -------
        np.array
            Optimised intensity values.

        """
        if not self.curveparams:
            print('No parameters yet. Run .fit_curves(...) first...')
        params = self.curveparams[primary]
        settings = self.settings_to_weights(settings)
        optisettings = beta.ppf(settings, params[0], params[1])
        return self.weights_to_settings(optisettings)

    def settings_to_weights(self, settings: List[int]) -> List[float]:
        """Convert a list of settings to a list of weights.

        Parameters
        ----------
        settings : list of int
            List of settings for device primaries, ranging from 0-max
            resolution for respective primary.

        Returns
        -------
        list
            List of weights.

        """
        return [float(s / r) for s, r in zip(settings, self.resolutions)]

    def weights_to_settings(self, weights: List[float]) -> List[int]:
        """Convert a list of weights to a list of settings.

        Parameters
        ----------
        weights : list of float
            List of weights for device primaries, ranging from 0.-1.

        Returns
        -------
        list
            List of settings.

        """
        return [int(w * r) for w, r in zip(weights, self.resolutions)]

    def spd_to_settings(self, target_spd, tolerance=1e-6):
        #breakpoint()
        target_xyY = colorfunc.spd_to_xyY(target_spd)

        def _objective(x0):
            xyY = colorfunc.spd_to_xyY(self.predict_multiprimary_spd(x0))
            error = target_xyY - xyY
            return sum(pow(error, 2))

        # Callback for global search
        def _callback(x, f, accepted):
            if accepted and tolerance is not None:
                if f < tolerance:
                    return True

        x0 = np.array([np.random.uniform(0, 1) for val in self.resolutions])
        bounds = [(0., 1.,) for primary in self.resolutions]

        minimizer_kwargs = {
            'method': 'SLSQP',
            'bounds': bounds,
            'options': {'maxiter': 500}
        }

        # Do global search
        res = basinhopping(
            func=_objective,
            x0=x0,
            niter=100,
            T=1.0,
            stepsize=0.5,
            minimizer_kwargs=minimizer_kwargs,
            take_step=None,
            accept_test=None,
            callback=_callback,
            interval=50,
            disp=True,
            niter_success=None,
            seed=None,
        )
        return res