Esempio n. 1
0
def msds_constant(
    k: Floating,
    labels: Sequence,
    shape: SpectralShape = SPECTRAL_SHAPE_DEFAULT,
    **kwargs: Any,
) -> MultiSpectralDistributions:
    """
    Return the multi-spectral distributions with given labels and given
    spectral shape filled with constant :math:`k` values.

    Parameters
    ----------
    k
        Constant :math:`k` to fill the multi-spectral distributions with.
    labels
        Names to use for the :class:`colour.SpectralDistribution` class
        instances.
    shape
        Spectral shape used to create the multi-spectral distributions.

    Other Parameters
    ----------------
    kwargs
        {:class:`colour.MultiSpectralDistributions`},
        See the documentation of the previously listed class.

    Returns
    -------
    :class:`colour.MultiSpectralDistributions`
        Constant :math:`k` filled multi-spectral distributions.

    Notes
    -----
    -   By default, the multi-spectral distributions will use the shape given
        by :attr:`colour.SPECTRAL_SHAPE_DEFAULT` attribute.

    Examples
    --------
    >>> msds = msds_constant(100, labels=['a', 'b', 'c'])
    >>> msds.shape
    SpectralShape(360.0, 780.0, 1.0)
    >>> msds[400]
    array([ 100.,  100.,  100.])
    >>> msds.labels  # doctest: +SKIP
    ['a', 'b', 'c']
    """

    settings = {"name": f"{k} Constant"}
    settings.update(kwargs)

    wavelengths = shape.range()
    values = full((len(wavelengths), len(labels)), k)

    return MultiSpectralDistributions(values,
                                      wavelengths,
                                      labels=labels,
                                      **settings)
Esempio n. 2
0
def load_TCS_CIE2017(shape: SpectralShape) -> MultiSpectralDistributions:
    """
    Load the *CIE 2017 Test Colour Samples* dataset appropriate for the given
    spectral shape.

    The datasets are cached and won't be loaded again on subsequent calls to
    this definition.

    Parameters
    ----------
    shape
        Spectral shape of the tested illuminant.

    Returns
    -------
    :class:`colour.MultiSpectralDistributions`
        *CIE 2017 Test Colour Samples* dataset.

    Examples
    --------
    >>> sds_tcs = load_TCS_CIE2017(SpectralShape(380, 780, 5))
    >>> len(sds_tcs.labels)
    99
    """

    global _CACHE_TCS_CIE2017

    interval = shape.interval

    attest(
        interval in (1, 5),
        "Spectral shape interval must be either 1nm or 5nm!",
    )

    filename = f"tcs_cfi2017_{as_int_scalar(interval)}_nm.csv.gz"

    if filename in _CACHE_TCS_CIE2017:
        return _CACHE_TCS_CIE2017[filename]

    data = np.genfromtxt(str(
        os.path.join(RESOURCES_DIRECTORY_CIE2017, filename)),
                         delimiter=",")
    labels = [f"TCS{i} (CIE 2017)" for i in range(99)]

    tcs = MultiSpectralDistributions(data[:, 1:], data[:, 0], labels)

    _CACHE_TCS_CIE2017[filename] = tcs

    return tcs
Esempio n. 3
0
def tcs_colorimetry_data(
    sd_irradiance: SpectralDistribution,
    sds_tcs: MultiSpectralDistributions,
    cmfs: MultiSpectralDistributions,
) -> Tuple[TCS_ColorimetryData_CIE2017, ...]:
    """
    Return the *test colour samples* colorimetry data under given test light
    source or reference illuminant spectral distribution for the
    *CIE 2017 Colour Fidelity Index* (CFI) computations.

    Parameters
    ----------
    sd_irradiance
        Test light source or reference illuminant spectral distribution, i.e.
        the irradiance emitter.
    sds_tcs
        *Test colour samples* spectral distributions.
    cmfs
        Standard observer colour matching functions.

    Returns
    -------
    :class:`tuple`
        *Test colour samples* colorimetry data under the given test light
        source or reference illuminant spectral distribution.

    Examples
    --------
    >>> delta_E_to_R_f(4.4410383190)  # doctest: +ELLIPSIS
    70.1208254...
    """

    XYZ_w = sd_to_XYZ(sd_ones(), cmfs, sd_irradiance)
    Y_b = 20
    L_A = 100
    surround = VIEWING_CONDITIONS_CIECAM02["Average"]

    tcs_data = []
    for sd_tcs in sds_tcs.to_sds():
        XYZ = sd_to_XYZ(sd_tcs, cmfs, sd_irradiance)
        CAM = XYZ_to_CIECAM02(XYZ, XYZ_w, L_A, Y_b, surround, True)
        JMh = tstack([CAM.J, CAM.M, CAM.h])
        Jpapbp = JMh_CIECAM02_to_CAM02UCS(JMh)

        tcs_data.append(
            TCS_ColorimetryData_CIE2017(sd_tcs.name, XYZ, CAM, JMh, Jpapbp))

    return tuple(tcs_data)
Esempio n. 4
0
def msds_constant(k, labels, shape=SPECTRAL_SHAPE_DEFAULT, dtype=None):
    """
    Returns the multi-spectral distributions with given labels and given
    spectral shape filled with constant :math:`k` values.

    Parameters
    ----------
    k : numeric
        Constant :math:`k` to fill the multi-spectral distributions with.
    labels : array_like
        Names to use for the :class:`colour.SpectralDistribution` class
        instances.
    shape : SpectralShape, optional
        Spectral shape used to create the multi-spectral distributions.
    dtype : type
        Data type used for the multi-spectral distributions.

    Returns
    -------
    MultiSpectralDistributions
        Constant :math:`k` filled multi-spectral distributions.

    Notes
    -----
    -   By default, the multi-spectral distributions will use the shape given
        by :attr:`colour.SPECTRAL_SHAPE_DEFAULT` attribute.

    Examples
    --------
    >>> msds = msds_constant(100, labels=['a', 'b', 'c'])
    >>> msds.shape
    SpectralShape(360.0, 780.0, 1.0)
    >>> msds[400]
    array([ 100.,  100.,  100.])
    >>> msds.labels  # doctest: +SKIP
    ['a', 'b', 'c']
    """

    if dtype is None:
        dtype = DEFAULT_FLOAT_DTYPE

    wavelengths = shape.range(dtype)
    values = full([len(wavelengths), len(labels)], k, dtype)

    name = '{0} Constant'.format(k)
    return MultiSpectralDistributions(
        values, wavelengths, name=name, labels=labels, dtype=dtype)
Esempio n. 5
0
def load_TCS_CIE2017(shape):
    """
    Loads the *CIE 2017 Test Colour Samples* dataset appropriate for the given
    spectral shape.

    The datasets are cached and won't be loaded again on subsequent calls to
    this definition.

    Parameters
    ----------
    shape : SpectralShape
        Spectral shape of the tested illuminant.

    Returns
    -------
    MultiSpectralDistributions
        *CIE 2017 Test Colour Samples* dataset.

    Examples
    --------
    >>> sds_tcs = load_TCS_CIE2017(SpectralShape(interval=5))
    >>> len(sds_tcs.labels)
    99
    """

    global _CACHE_TCS_CIE2017

    interval = shape.interval

    assert interval in (1, 5), (
        'Spectral shape interval must be either 1nm or 5nm!')

    filename = 'tcs_cfi2017_{0}_nm.csv.gz'.format(as_int(interval))

    if filename in _CACHE_TCS_CIE2017:
        return _CACHE_TCS_CIE2017[filename]

    data = np.genfromtxt(str(
        os.path.join(RESOURCES_DIRECTORY_CIE2017, filename)),
                         delimiter=',')
    labels = ['TCS{0} (CIE 2017)'.format(i) for i in range(99)]

    return MultiSpectralDistributions(data[:, 1:], data[:, 0], labels)
Esempio n. 6
0
            0.00413520,
            0.78492409,
        ],
        [
            0.04727245,
            0.32210270,
            0.22679484,
            0.31613642,
            0.11242847,
            0.00244144,
        ],
    ],
])

MSDS_TWO: MultiSpectralDistributions = MultiSpectralDistributions(
    np.transpose(np.reshape(DATA_TWO, [-1, 6])),
    SpectralShape(400, 700, 60).range(),
)

TVS_D65_INTEGRATION_MSDS: NDArray = np.array([
    [7.50219602, 3.95048275, 8.40152163],
    [26.92629005, 15.07170066, 28.71020457],
    [16.70060700, 28.21421317, 25.64802044],
    [11.57577260, 8.64108703, 6.57740493],
    [18.73108262, 35.07369122, 30.14365007],
    [45.16559608, 39.61411218, 43.68158810],
    [8.17318743, 13.09236381, 25.93755134],
    [22.46715798, 19.31066951, 7.95954422],
    [6.58106180, 2.52865132, 11.09122159],
    [43.91745731, 27.98043364, 11.73313699],
    [8.53693599, 19.70195654, 17.70110118],
    [23.91114755, 26.21471641, 30.67613685],
Esempio n. 7
0
    [[0.01367208, 0.09127947, 0.01524376, 0.02810712, 0.19176012, 0.04299992],
     [0.01591516, 0.31454948, 0.08416876, 0.09071489, 0.71026170, 0.04374762],
     [0.00959792, 0.25822842, 0.41388571, 0.22275120, 0.00407416, 0.37439537],
     [0.01106279, 0.07090867, 0.02204929, 0.12487984, 0.18168917, 0.00202945],
     [0.01791409, 0.29707789, 0.56295109, 0.23752193, 0.00236515, 0.58190280],
     [0.10565346, 0.46204320, 0.19180590, 0.56250858, 0.42085907, 0.00270085]],
    [[0.04325933, 0.26825359, 0.23732357, 0.05175860, 0.01181048, 0.08233768],
     [0.02577249, 0.08305486, 0.04303044, 0.32298771, 0.23022813, 0.00813306],
     [0.02484169, 0.12027161, 0.00541695, 0.00654612, 0.18603799, 0.36247808],
     [0.01861601, 0.12924391, 0.00785840, 0.40062562, 0.94044405, 0.32133976],
     [0.03102159, 0.16815442, 0.37186235, 0.08610666, 0.00413520, 0.78492409],
     [0.04727245, 0.32210270, 0.22679484, 0.31613642, 0.11242847, 0.00244144]],
])

MSDS = MultiSpectralDistributions(
    np.transpose(np.reshape(MSDS_ARRAY, [-1, 6])),
    SpectralShape(400, 700, 60).range())

XYZ_D65_INTEGRATION_MSDS = np.array([
    [7.50233492, 3.95038753, 8.40338691],
    [26.92687731, 15.07139447, 28.71690972],
    [16.70142165, 28.21458593, 25.65389977],
    [11.57556582, 8.64089371, 6.57886800],
    [18.73219899, 35.07426727, 30.15041858],
    [45.16536627, 39.61360393, 43.69092793],
    [8.17423105, 13.09267724, 25.94320661],
    [22.46636804, 19.31025987, 7.96118575],
    [6.58131705, 2.52857864, 11.09362054],
    [43.91622393, 27.97952698, 11.73575112],
    [8.53774314, 19.70241814, 17.70479540],
    [23.91149458, 26.21467120, 30.68294836],
Esempio n. 8
0
        [0.34488011934469100, 0.33148268992224300, 0.32363699470796100],
        [0.34191787732329100, 0.33198455035238900, 0.32609730884690000],
        [0.33953109298712900, 0.33234117252254500, 0.32812736934018400],
        [0.33716950377436700, 0.33291200941553900, 0.32991797595888800],
        [0.33617201852771700, 0.33291927969521400, 0.33090790121664900],
        [0.33516744343336300, 0.33302767257885600, 0.33180363309599500],
        [0.33442162530646300, 0.33317970467326000, 0.33239662725536100],
        [0.33400876037640200, 0.33324703097454900, 0.33274078072682400],
        [0.33391579279008200, 0.33325934921060100, 0.33282085708148900],
        [0.33381845494636700, 0.33327505027938300, 0.33290173128344400],
        [0.33367277492845600, 0.33329432844873200, 0.33302596748863200],
        [0.33356951340559100, 0.33330942495777500, 0.33311108308149700],
    ]
)

MSDS_BASIS_FUNCTIONS_sRGB_MALLETT2019: MultiSpectralDistributions = (
    MultiSpectralDistributions(
        DATA_BASIS_FUNCTIONS_sRGB_MALLETT2019,
        SPECTRAL_SHAPE_sRGB_MALLETT2019.range(),
        name="Basis Functions - sRGB - Mallett 2019",
        labels=("red", "green", "blue"),
    )
)
"""
*Mallett and Yuksel (2019)* basis functions for the *sRGB* colourspace.

References
----------
:cite:`Mallett2019`
"""
Esempio n. 9
0
    [0.35760870152308100, 0.32914390227898000, 0.31324730130288600],
    [0.34871280010839300, 0.33080872680368200, 0.32047832512463300],
    [0.34488011934469100, 0.33148268992224300, 0.32363699470796100],
    [0.34191787732329100, 0.33198455035238900, 0.32609730884690000],
    [0.33953109298712900, 0.33234117252254500, 0.32812736934018400],
    [0.33716950377436700, 0.33291200941553900, 0.32991797595888800],
    [0.33617201852771700, 0.33291927969521400, 0.33090790121664900],
    [0.33516744343336300, 0.33302767257885600, 0.33180363309599500],
    [0.33442162530646300, 0.33317970467326000, 0.33239662725536100],
    [0.33400876037640200, 0.33324703097454900, 0.33274078072682400],
    [0.33391579279008200, 0.33325934921060100, 0.33282085708148900],
    [0.33381845494636700, 0.33327505027938300, 0.33290173128344400],
    [0.33367277492845600, 0.33329432844873200, 0.33302596748863200],
    [0.33356951340559100, 0.33330942495777500, 0.33311108308149700],
])

MSDS_BASIS_FUNCTIONS_sRGB_MALLETT2019 = MultiSpectralDistributions(
    DATA_BASIS_FUNCTIONS_sRGB_MALLETT2019,
    SPECTRAL_SHAPE_sRGB_MALLETT2019.range(),
    name='Basis Functions - sRGB - Mallett 2019',
    labels=('red', 'green', 'blue'))
"""
*Mallett and Yuksel (2019)* basis functions for the *sRGB* colourspace.

References
----------
:cite:`Mallett2019`

MSDS_BASIS_FUNCTIONS_sRGB_MALLETT2019 : MultiSpectralDistributions
"""
Esempio n. 10
0
def spectral_primary_decomposition_Mallett2019(
    colourspace: RGB_Colourspace,
    cmfs: Optional[MultiSpectralDistributions] = None,
    illuminant: Optional[SpectralDistribution] = None,
    metric: Callable = np.linalg.norm,
    metric_args: Tuple = tuple(),
    optimisation_kwargs: Optional[Dict] = None,
) -> MultiSpectralDistributions:
    """
    Perform the spectral primary decomposition as described in *Mallett and
    Yuksel (2019)* for given *RGB* colourspace.

    Parameters
    ----------
    colourspace
        *RGB* colourspace.
    cmfs
        Standard observer colour matching functions, default to the
        *CIE 1931 2 Degree Standard Observer*.
    illuminant
        Illuminant spectral distribution, default to
        *CIE Standard Illuminant D65*.
    metric
        Function to be minimised, i.e. the objective function.

            ``metric(basis, *metric_args) -> float``

        where ``basis`` is three reflectances concatenated together, each
        with a shape matching ``shape``.
    metric_args
        Additional arguments passed to ``metric``.
    optimisation_kwargs
        Parameters for :func:`scipy.optimize.minimize` definition.

    Returns
    -------
    :class:`colour.MultiSpectralDistributions`
        Basis functions for given *RGB* colourspace.

    References
    ----------
    :cite:`Mallett2019`

    Notes
    -----
    -   In-addition to the *BT.709* primaries used by the *sRGB* colourspace,
        :cite:`Mallett2019` tried *BT.2020*, *P3 D65*, *Adobe RGB 1998*,
        *NTSC (1987)*, *Pal/Secam*, *ProPhoto RGB*,
        and *Adobe Wide Gamut RGB* primaries, every one of which encompasses a
        larger (albeit not-always-enveloping) set of *CIE L\\*a\\*b\\** colours
        than BT.709. Of these, only *Pal/Secam* produces a feasible basis,
        which is relatively unsurprising since it is very similar to *BT.709*,
        whereas the others are significantly larger.

    Examples
    --------
    >>> from colour import MSDS_CMFS, SDS_ILLUMINANTS, SpectralShape
    >>> from colour.models import RGB_COLOURSPACE_PAL_SECAM
    >>> from colour.utilities import numpy_print_options
    >>> cmfs = (
    ...     MSDS_CMFS['CIE 1931 2 Degree Standard Observer'].
    ...     copy().align(SpectralShape(360, 780, 10))
    ... )
    >>> illuminant = SDS_ILLUMINANTS['D65'].copy().align(cmfs.shape)
    >>> msds = spectral_primary_decomposition_Mallett2019(
    ...     RGB_COLOURSPACE_PAL_SECAM, cmfs, illuminant, optimisation_kwargs={
    ...         'options': {'ftol': 1e-5}
    ...     }
    ... )
    >>> with numpy_print_options(suppress=True):
    ...     print(msds)  # doctest: +SKIP
    [[ 360.            0.3395134...    0.3400214...    0.3204650...]
     [ 370.            0.3355246...    0.3338028...    0.3306724...]
     [ 380.            0.3376707...    0.3185578...    0.3437715...]
     [ 390.            0.3178866...    0.3351754...    0.3469378...]
     [ 400.            0.3045154...    0.3248376...    0.3706469...]
     [ 410.            0.2935652...    0.2919463...    0.4144884...]
     [ 420.            0.1875740...    0.1853729...    0.6270530...]
     [ 430.            0.0167983...    0.054483 ...    0.9287186...]
     [ 440.            0.       ...    0.       ...    1.       ...]
     [ 450.            0.       ...    0.       ...    1.       ...]
     [ 460.            0.       ...    0.       ...    1.       ...]
     [ 470.            0.       ...    0.0458044...    0.9541955...]
     [ 480.            0.       ...    0.2960917...    0.7039082...]
     [ 490.            0.       ...    0.5042592...    0.4957407...]
     [ 500.            0.       ...    0.6655795...    0.3344204...]
     [ 510.            0.       ...    0.8607541...    0.1392458...]
     [ 520.            0.       ...    0.9999998...    0.0000001...]
     [ 530.            0.       ...    1.       ...    0.       ...]
     [ 540.            0.       ...    1.       ...    0.       ...]
     [ 550.            0.       ...    1.       ...    0.       ...]
     [ 560.            0.       ...    0.9924229...    0.       ...]
     [ 570.            0.       ...    0.9970703...    0.0025673...]
     [ 580.            0.0396002...    0.9028231...    0.0575766...]
     [ 590.            0.7058973...    0.2941026...    0.       ...]
     [ 600.            1.       ...    0.       ...    0.       ...]
     [ 610.            1.       ...    0.       ...    0.       ...]
     [ 620.            1.       ...    0.       ...    0.       ...]
     [ 630.            1.       ...    0.       ...    0.       ...]
     [ 640.            0.9835925...    0.0100166...    0.0063908...]
     [ 650.            0.7878949...    0.1265097...    0.0855953...]
     [ 660.            0.5987994...    0.2051062...    0.1960942...]
     [ 670.            0.4724493...    0.2649623...    0.2625883...]
     [ 680.            0.3989806...    0.3007488...    0.3002704...]
     [ 690.            0.3666586...    0.3164003...    0.3169410...]
     [ 700.            0.3497806...    0.3242863...    0.3259329...]
     [ 710.            0.3563736...    0.3232441...    0.3203822...]
     [ 720.            0.3362624...    0.3326209...    0.3311165...]
     [ 730.            0.3245015...    0.3365982...    0.3389002...]
     [ 740.            0.3335520...    0.3320670...    0.3343808...]
     [ 750.            0.3441287...    0.3291168...    0.3267544...]
     [ 760.            0.3343705...    0.3330132...    0.3326162...]
     [ 770.            0.3274633...    0.3305704...    0.3419662...]
     [ 780.            0.3475263...    0.3262331...    0.3262404...]]
    """

    cmfs, illuminant = handle_spectral_arguments(cmfs, illuminant)

    N = len(cmfs.shape)

    R_to_XYZ = np.transpose(illuminant.values[..., np.newaxis] * cmfs.values /
                            (np.sum(cmfs.values[:, 1] * illuminant.values)))
    R_to_RGB = np.dot(colourspace.matrix_XYZ_to_RGB, R_to_XYZ)
    basis_to_RGB = block_diag(R_to_RGB, R_to_RGB, R_to_RGB)

    primaries = np.reshape(np.identity(3), 9)

    # Ensure that the reflectances correspond to the correct RGB colours.
    colour_match = LinearConstraint(basis_to_RGB, primaries, primaries)

    # Ensure that the reflectances are bounded by [0, 1].
    energy_conservation = Bounds(np.zeros(3 * N), np.ones(3 * N))

    # Ensure that the sum of the three bases is bounded by [0, 1].
    sum_matrix = np.transpose(np.tile(np.identity(N), (3, 1)))
    sum_constraint = LinearConstraint(sum_matrix, np.zeros(N), np.ones(N))

    optimisation_settings = {
        "method": "SLSQP",
        "constraints": [colour_match, sum_constraint],
        "bounds": energy_conservation,
        "options": {
            "ftol": 1e-10,
        },
    }

    if optimisation_kwargs is not None:
        optimisation_settings.update(optimisation_kwargs)

    result = minimize(metric,
                      args=metric_args,
                      x0=np.zeros(3 * N),
                      **optimisation_settings)

    basis_functions = np.transpose(np.reshape(result.x, (3, N)))

    return MultiSpectralDistributions(
        basis_functions,
        cmfs.shape.range(),
        name=f"Basis Functions - {colourspace.name} - Mallett (2019)",
        labels=("red", "green", "blue"),
    )
Esempio n. 11
0
def spectral_primary_decomposition_Mallett2019(
        colourspace,
        cmfs=MSDS_CMFS_STANDARD_OBSERVER[
            'CIE 1931 2 Degree Standard Observer'],
        illuminant=SDS_ILLUMINANTS['D65'],
        metric=np.linalg.norm,
        metric_args=tuple(),
        optimisation_kwargs=None):
    """
    Performs the spectral primary decomposition as described in *Mallett and
    Yuksel (2019)* for given *RGB* colourspace.

    Parameters
    ----------
    colourspace: RGB_Colourspace
        *RGB* colourspace.
    cmfs : XYZ_ColourMatchingFunctions, optional
        Standard observer colour matching functions.
    illuminant : SpectralDistribution, optional
        Illuminant spectral distribution.
    metric : unicode, optional
        Function to be minimised, i.e. the objective function.

            ``metric(basis, *metric_args) -> float``

        where ``basis`` is three reflectances concatenated together, each
        with a shape matching ``shape``.
    metric_args : tuple, optional
        Additional arguments passed to ``metric``.
    optimisation_kwargs : dict_like, optional
        Parameters for :func:`scipy.optimize.minimize` definition.

    Returns
    -------
    MultiSpectralDistributions
        Basis functions for given *RGB* colourspace.

    References
    ----------
    :cite:`Mallett2019`

    Notes
    -----
    -   In-addition to the *BT.709* primaries used by the *sRGB* colourspace,
        :cite:`Mallett2019` tried *BT.2020*, *P3 D65*, *Adobe RGB 1998*,
        *NTSC (1987)*, *Pal/Secam*, *ProPhoto RGB*,
        and *Adobe Wide Gamut RGB* primaries, every one of which encompasses a
        larger (albeit not-always-enveloping) set of *CIE L\\*a\\*b\\** colours
        than BT.709. Of these, only *Pal/Secam* produces a feasible basis,
        which is relatively unsurprising since it is very similar to *BT.709*,
        whereas the others are significantly larger.

    Examples
    --------
    >>> from colour.colorimetry import SpectralShape
    >>> from colour.models import RGB_COLOURSPACE_PAL_SECAM
    >>> from colour.utilities import numpy_print_options
    >>> cmfs = (
    ...     MSDS_CMFS_STANDARD_OBSERVER['CIE 1931 2 Degree Standard Observer'].
    ...     copy().align(SpectralShape(360, 780, 10))
    ... )
    >>> illuminant = SDS_ILLUMINANTS['D65'].copy().align(cmfs.shape)
    >>> msds = spectral_primary_decomposition_Mallett2019(
    ...     RGB_COLOURSPACE_PAL_SECAM, cmfs, illuminant, optimisation_kwargs={
    ...         'options': {'ftol': 1e-5}
    ...     }
    ... )
    >>> with numpy_print_options(suppress=True):
    ...     # Doctests skip for Python 2.x compatibility.
    ...     print(msds)  # doctest: +SKIP
    [[ 360.            0.3324728...    0.3332663...    0.3342608...]
     [ 370.            0.3323307...    0.3327746...    0.3348946...]
     [ 380.            0.3341115...    0.3323995...    0.3334889...]
     [ 390.            0.3337570...    0.3298092...    0.3364336...]
     [ 400.            0.3209352...    0.3218213...    0.3572433...]
     [ 410.            0.2881025...    0.2837628...    0.4281346...]
     [ 420.            0.1836749...    0.1838893...    0.6324357...]
     [ 430.            0.0187212...    0.0529655...    0.9283132...]
     [ 440.            0.       ...    0.       ...    1.       ...]
     [ 450.            0.       ...    0.       ...    1.       ...]
     [ 460.            0.       ...    0.       ...    1.       ...]
     [ 470.            0.       ...    0.0509556...    0.9490443...]
     [ 480.            0.       ...    0.2933996...    0.7066003...]
     [ 490.            0.       ...    0.5001396...    0.4998603...]
     [ 500.            0.       ...    0.6734805...    0.3265195...]
     [ 510.            0.       ...    0.8555213...    0.1444786...]
     [ 520.            0.       ...    0.9999985...    0.0000014...]
     [ 530.            0.       ...    1.       ...    0.       ...]
     [ 540.            0.       ...    1.       ...    0.       ...]
     [ 550.            0.       ...    1.       ...    0.       ...]
     [ 560.            0.       ...    0.9924229...    0.       ...]
     [ 570.            0.       ...    0.9913344...    0.0083032...]
     [ 580.            0.0370289...    0.9145168...    0.0484542...]
     [ 590.            0.7100075...    0.2898477...    0.0001446...]
     [ 600.            1.       ...    0.       ...    0.       ...]
     [ 610.            1.       ...    0.       ...    0.       ...]
     [ 620.            1.       ...    0.       ...    0.       ...]
     [ 630.            1.       ...    0.       ...    0.       ...]
     [ 640.            0.9711347...    0.0137659...    0.0150993...]
     [ 650.            0.7996619...    0.1119379...    0.0884001...]
     [ 660.            0.6064640...    0.202815 ...    0.1907209...]
     [ 670.            0.4662959...    0.2675037...    0.2662005...]
     [ 680.            0.4010958...    0.2998989...    0.2990052...]
     [ 690.            0.3617485...    0.3208921...    0.3173592...]
     [ 700.            0.3496691...    0.3247855...    0.3255453...]
     [ 710.            0.3433979...    0.3273540...    0.329248 ...]
     [ 720.            0.3358860...    0.3345583...    0.3295556...]
     [ 730.            0.3349498...    0.3314232...    0.3336269...]
     [ 740.            0.3359954...    0.3340147...    0.3299897...]
     [ 750.            0.3310392...    0.3327595...    0.3362012...]
     [ 760.            0.3346883...    0.3314158...    0.3338957...]
     [ 770.            0.3332167...    0.333371 ...    0.3334122...]
     [ 780.            0.3319670...    0.3325476...    0.3354852...]]
    """

    if illuminant.shape != cmfs.shape:
        runtime_warning(
            'Aligning "{0}" illuminant shape to "{1}" colour matching '
            'functions shape.'.format(illuminant.name, cmfs.name))
        illuminant = illuminant.copy().align(cmfs.shape)

    N = len(cmfs.shape)

    R_to_XYZ = np.transpose(
        np.expand_dims(illuminant.values, axis=1) * cmfs.values /
        (np.sum(cmfs.values[:, 1] * illuminant.values)))
    R_to_RGB = np.dot(colourspace.matrix_XYZ_to_RGB, R_to_XYZ)
    basis_to_RGB = block_diag(R_to_RGB, R_to_RGB, R_to_RGB)

    primaries = np.identity(3).reshape(9)

    # Ensure the reflectances correspond to the correct RGB colours.
    colour_match = LinearConstraint(basis_to_RGB, primaries, primaries)

    # Ensure the reflectances are bounded by [0, 1].
    energy_conservation = Bounds(np.zeros(3 * N), np.ones(3 * N))

    # Ensure the sum of the three bases is bounded by [0, 1].
    sum_matrix = np.transpose(np.tile(np.identity(N), (3, 1)))
    sum_constraint = LinearConstraint(sum_matrix, np.zeros(N), np.ones(N))

    optimisation_settings = {
        'method': 'SLSQP',
        'constraints': [colour_match, sum_constraint],
        'bounds': energy_conservation,
        'options': {
            'ftol': 1e-10,
        }
    }

    if optimisation_kwargs is not None:
        optimisation_settings.update(optimisation_kwargs)

    result = minimize(metric,
                      args=metric_args,
                      x0=np.zeros(3 * N),
                      **optimisation_settings)

    basis_functions = np.transpose(result.x.reshape(3, N))

    return MultiSpectralDistributions(
        basis_functions,
        cmfs.shape.range(),
        name='Basis Functions - {0} - Mallett (2019)'.format(colourspace.name),
        labels=('red', 'green', 'blue'))