Basic Concepts#

This page puts an emphasis on basic concepts of Colour, those are important to understand.

Object Name Categorisation#

The API tries to bundle the objects by categories by naming them with common prefixes which makes introspection and auto-completion easier.

For example, in IPython or Jupyter Notebook, most of the definitions pertaining to the spectral distribution handling can be found as follows:

In [1]: import colour

In [2]: colour.sd_
 sd_blackbody()                            sd_gaussian()                             sd_rayleigh_scattering()                  sd_zeros
 sd_CIE_illuminant_D_series()              sd_mesopic_luminous_efficiency_function() sd_single_led()
 sd_CIE_standard_illuminant_A()            sd_multi_leds()                           sd_to_aces_relative_exposure_values()
 sd_constant()                             sd_ones()                                 sd_to_XYZ

Likewise, for the spectral distribution handling related attributes:

In [2]: colour.SD
                  SD_GAUSSIAN_METHODS   SD_TO_XYZ_METHODS     SDS_ILLUMINANTS       SDS_LIGHT_SOURCES
                  SD_MULTI_LEDS_METHODS SDS_COLOURCHECKERS    SDS_LEFS
                  SD_SINGLE_LED_METHODS SDS_FILTERS           SDS_LENSES

Similarly, all the RGB colourspaces can be individually accessed from the colour.models namespace:

In [2]: colour.models.RGB_COLOURSPACE
                         RGB_COLOURSPACE_ACES2065_1           RGB_COLOURSPACE_ACESPROXY            RGB_COLOURSPACE_APPLE_RGB            RGB_COLOURSPACE_BT470_525
                         RGB_COLOURSPACE_ACESCC               RGB_COLOURSPACE_ADOBE_RGB1998        RGB_COLOURSPACE_BEST_RGB             RGB_COLOURSPACE_BT470_625
                         RGB_COLOURSPACE_ACESCCT              RGB_COLOURSPACE_ADOBE_WIDE_GAMUT_RGB RGB_COLOURSPACE_BETA_RGB             RGB_COLOURSPACE_BT709                >
                         RGB_COLOURSPACE_ACESCG               RGB_COLOURSPACE_ARRI_WIDE_GAMUT_3    RGB_COLOURSPACE_BT2020               RGB_COLOURSPACE_CIE_RGB

Abbreviations#

The following abbreviations are in use in Colour:

  • CAM : Colour Appearance Model

  • CCS : Chromaticity Coordinates

  • CCTF : Colour Component Transfer Function

  • CCT : Correlated Colour Temperature

  • CMY : Cyan, Magenta, Yellow

  • CMYK : Cyan, Magenta, Yellow, Black

  • CVD : Colour Vision Deficiency

  • CV : Code Value

  • EOTF : Electro-Optical Transfer Function

  • IDT : Input Device Transform

  • MSDS : Multi-Spectral Distributions

  • OETF : Optical-Electrical Transfer Function

  • OOTF : Optical-Optical Transfer Function

  • SD : Spectral Distribution

  • TVS : Tristimulus Values

N-Dimensional Array Support#

Most of Colour definitions are fully vectorised and support n-dimensional array by leveraging Numpy.

While it is recommended to use ndarray as input for the API objects, it is possible to use tuples or lists:

import colour

xyY = (0.4316, 0.3777, 0.1008)
colour.xyY_to_XYZ(xyY)
array([ 0.11518475,  0.1008    ,  0.05089373])
xyY = [0.4316, 0.3777, 0.1008]
colour.xyY_to_XYZ(xyY)
array([ 0.11518475,  0.1008    ,  0.05089373])
xyY = [
    (0.4316, 0.3777, 0.1008),
    (0.4316, 0.3777, 0.1008),
    (0.4316, 0.3777, 0.1008),
]
colour.xyY_to_XYZ(xyY)
array([[ 0.11518475,  0.1008    ,  0.05089373],
       [ 0.11518475,  0.1008    ,  0.05089373],
       [ 0.11518475,  0.1008    ,  0.05089373]])

As shown in the above example, there is widespread support for n-dimensional arrays:

import numpy as np

xyY = np.array([0.4316, 0.3777, 0.1008])
xyY = np.tile(xyY, (6, 1))
colour.xyY_to_XYZ(xyY)
array([[ 0.11518475,  0.1008    ,  0.05089373],
       [ 0.11518475,  0.1008    ,  0.05089373],
       [ 0.11518475,  0.1008    ,  0.05089373],
       [ 0.11518475,  0.1008    ,  0.05089373],
       [ 0.11518475,  0.1008    ,  0.05089373],
       [ 0.11518475,  0.1008    ,  0.05089373]])
colour.xyY_to_XYZ(xyY.reshape([2, 3, 3]))
array([[[ 0.11518475,  0.1008    ,  0.05089373],
        [ 0.11518475,  0.1008    ,  0.05089373],
        [ 0.11518475,  0.1008    ,  0.05089373]],

       [[ 0.11518475,  0.1008    ,  0.05089373],
        [ 0.11518475,  0.1008    ,  0.05089373],
        [ 0.11518475,  0.1008    ,  0.05089373]]])

Which enables image processing:

RGB = colour.read_image("_static/Logo_Small_001.png")
RGB = RGB[..., 0:3]  # Discarding alpha channel.
XYZ = colour.sRGB_to_XYZ(RGB)
colour.plotting.plot_image(XYZ, text_kwargs={"text": "sRGB to XYZ"})
_images/Basics_Logo_Small_001_CIE_XYZ.png

Spectral Representation and Continuous Signal#

Floating Point Wavelengths#

Colour current representation of spectral data is atypical and has been influenced by the failures and shortcomings of the previous implementation that required less than ideal code to support floating point wavelengths. Wavelengths should not have to be defined as integer values and it is effectively common to get data from instruments whose domain is returned as floating point values.

For example, the data from an Ocean Insight (Optics) STS-VIS spectrometer is typically saved with 3 digits decimal precision:

Data from Subt2_14-36-15-210.txt Node

Date: Sat Nov 17 14:36:15 NZDT 2018
User: kelsolaar
Spectrometer: S12286
Trigger mode: 0
Resolution mode: 1024 pixels
Integration Time (sec): 5.000000E0
Scans to average: 3
Nonlinearity correction enabled: true
Boxcar width: 3
Baseline correction enabled: true
XAxis mode: Wavelengths
Number of Pixels in Spectrum: 1024
# >>>>>Begin Spectral Data<<<<<
338.028     279.71
338.482     285.43
338.936     291.33
...
821.513     3112.65
822.008     3133.74
822.503     3107.11

A solution to the problem is to quantize the data at integer values but it is often non-desirable. The spectra representation implementation prior to Colour 0.3.11 was relying on a custom mutable mapping which was allowing to retrieve decimal keys within a given precision:

data_1 = {0.1999999998: "Nemo", 0.2000000000: "John"}
apm_1 = ArbitraryPrecisionMapping(data_1, key_decimals=10)
tuple(apm_1.keys())
(0.1999999998, 0.2)
apm_2 = ArbitraryPrecisionMapping(data_1, key_decimals=7)
tuple(apm_2.keys())
(0.2,)

While functional, the approach was brittle and not elegant which triggered a significant amount of rework.

Continuous Signal#

All the spectral distributions in Colour are instances of the colour.SpectralDistribution class (or its sub-classes), a sub-class of the colour.continuous.Signal class which is itself an implementation of the colour.continuous.AbstractContinuousFunction ABCMeta class:

Inheritance diagram of colour.SpectralDistribution

Likewise, the multi-spectral distributions are instances colour.MultiSpectralDistributions class (or its sub-classes), a sub-class of the colour.continuous.MultiSignals class which is a container for multiple colour.continuous.Signal sub-class instances and also implements the colour.continuous.AbstractContinuousFunction ABCMeta class.

Inheritance diagram of colour.MultiSpectralDistributions

The colour.continuous.Signal class implements the Signal.function() method so that evaluating the function for any independent domain \(x \in\mathbb{R}\) variable returns a corresponding range \(y \in\mathbb{R}\) variable.

It adopts an interpolating function encapsulated inside an extrapolating function. The resulting function independent domain, stored as discrete values in the colour.continuous.Signal.domain attribute corresponds with the function dependent and already known range stored in the colour.continuous.Signal.range attribute.

Consequently, it is possible to get the value of a spectral distribution at any given wavelength:

data = {
    500: 0.0651,
    520: 0.0705,
    540: 0.0772,
    560: 0.0870,
    580: 0.1128,
    600: 0.1360,
}
sd = colour.SpectralDistribution(data)
sd[555.5]
0.083453673782958995

Getting, Setting, Indexing and Slicing#

Attention

Indexing a spectral distribution (or multi-spectral distribution) with a numeric (or a numeric sequence) returns the corresponding value(s). Indexing a spectral distribution (or multi-spectral distribution) with a slice returns the values for the corresponding wavelength indexes.

While it is tempting to think that the colour.SpectralDistribution and colour.MultiSpectralDistributions classes behave like Numpy’s ndarray, they do not entirely and some peculiarities exist that make them different.

An important difference lies in the behaviour with respect to getting and setting the values of the data.

Getting the value(s) for a single (or multiple wavelengths) is done by indexing the colour.SpectralDistribution (or colour.MultiSpectralDistributions) class with the a single numeric or array of numeric wavelengths, e.g. sd[555.5] or sd[555.25, 555.25, 555.75].

However, if getting the values using a slice class instance, e.g. sd[0:3], the underlying discrete values for the indexes represented by the slice class instance are returned instead.

As shown in the previous section, getting the value of a wavelength is done as follows:

data = {
    500: 0.0651,
    520: 0.0705,
    540: 0.0772,
    560: 0.0870,
    580: 0.1128,
    600: 0.1360,
}
sd = colour.SpectralDistribution(data)
sd[555]
0.083135180664062502,

Multiple wavelength values can be retrieved as follows:

sd[(555.0, 556.25, 557.5, 558.75, 560.0)]
array([ 0.08313518,  0.08395997,  0.08488108,  0.085897  ,  0.087     ])

However, slices will return the values for the corresponding wavelength indexes:

sd[0:3]
array([ 0.0651,  0.0705,  0.0772])
sd[:]
array([ 0.0651,  0.0705,  0.0772,  0.087 ,  0.1128,  0.136 ])

Note

Indexing a multi-spectral distribution is achieved similarly, it can however be sliced along multiple axes because the data is2-dimensional, e.g. msds[0:3, 0:2].

A copy of the underlying colour.SpectralDistribution and colour.MultiSpectralDistributions classes discretized data can be accessed via the wavelengths and values properties. However, it cannot be changed directly via the properties or slicing:

Attention

The data returned by the wavelengths and values properties is a copy of the underlying colour.SpectralDistribution and colour.MultiSpectralDistributions classes discretized data: It can only be changed indirectly.

data = {
    500: 0.0651,
    520: 0.0705,
    540: 0.0772,
    560: 0.0870,
    580: 0.1128,
    600: 0.1360,
}
sd = colour.SpectralDistribution(data)
# Note: The wavelength 500nm is at index 0.
sd.values[0] = 0
sd[500]
0.065100000000000019

Instead, the values can be set indirectly:

values = sd.values
values[0] = 0
sd.values = values
sd.values
array([ 0.    ,  0.0705,  0.0772,  0.087 ,  0.1128,  0.136 ])

Domain-Range Scales#

Note

This section contains important information.

Colour adopts 4 main input domains and output ranges:

  • Scalars usually in domain-range [0, 1] (or [0, 10] for Munsell Value).

  • Percentages usually in domain-range [0, 100].

  • Degrees usually in domain-range [0, 360].

  • Integers usually in domain-range [0, 2**n -1] where n is the bit depth.

It is error prone but it is also a direct consequence of the inconsistency of the colour science field itself. We have discussed at length about this and we were leaning toward normalisation of the whole API to domain-range [0, 1], we never committed for reasons highlighted by the following points:

  • Colour Scientist performing computations related to Munsell Renotation System would be very surprised if the output Munsell Value was in range [0, 1] or [0, 100].

  • A Visual Effect Industry artist would be astonished to find out that conversion from CIE XYZ to sRGB was yielding values in range [0, 100].

However benefits of having a consistent and predictable domain-range scale are numerous thus with Colour 0.3.12 we have introduced a mechanism to allow users to work within one of the two available domain-range scales.

Scale - Reference#

‘Reference’ is the default domain-range scale of Colour, objects adopt the implemented reference, i.e. paper, publication, etc.., domain-range scale.

The ‘Reference’ domain-range scale is inconsistent, e.g. colour appearance models, spectral conversions are typically in domain-range [0, 100] while RGB models will operate in domain-range [0, 1]. Some objects, e.g. colour.colorimetry.lightness_Fairchild2011() definition have mismatched domain-range: input domain [0, 1] and output range [0, 100].

Scale - 1#

‘1’ is a domain-range scale converting all the relevant objects from Colour public API to domain-range [0, 1]:

  • Scalars in domain-range [0, 10], e.g Munsell Value are scaled by 10.

  • Percentages in domain-range [0, 100] are scaled by 100.

  • Degrees in domain-range [0, 360] are scaled by 360.

  • Integers in domain-range [0, 2**n -1] where n is the bit depth are scaled by 2**n -1.

  • Dimensionless values are unaffected and are indicated with DN.

  • Unaffected values are unaffected and are indicated with UN.

Warning

The conversion to ‘1’ domain-range scale is a soft normalisation and similarly to the ‘Reference’ domain-range scale it is normal to encounter values exceeding 1, e.g. High Dynamic Range Imagery (HDRI) or negative values, e.g. out-of-gamut RGB colourspace values. Some definitions such as colour.models.eotf_ST2084() which decodes absolute luminance values are not affected by any domain-range scales and are indicated with UN.

Understanding the Domain-Range Scale of an Object#

Using colour.adaptation.chromatic_adaptation_CIE1994() definition docstring as an example, the Notes section features two tables.

The first table is for the domain, and lists the input arguments affected by the two domain-range scales and which normalisation they should adopt depending the domain-range scale in use:

Domain

Scale - Reference

Scale - 1

XYZ_1

[0, 100]

[0, 1]

Y_o

[0, 100]

[0, 1]

The second table is for the range and lists the return value of the definition:

Range

Scale - Reference

Scale - 1

XYZ_2

[0, 100]

[0, 1]

Working with the Domain-Range Scales#

The current domain-range scale is returned with the colour.get_domain_range_scale() definition:

import colour

colour.get_domain_range_scale()
u'reference'

Changing from the ‘Reference’ default domain-range scale to ‘1’ is done with the colour.set_domain_range_scale() definition:

XYZ_1 = [28.00, 21.26, 5.27]
xy_o1 = [0.4476, 0.4074]
xy_o2 = [0.3127, 0.3290]
Y_o = 20
E_o1 = 1000
E_o2 = 1000
colour.adaptation.chromatic_adaptation_CIE1994(
    XYZ_1, xy_o1, xy_o2, Y_o, E_o1, E_o2
)
array([ 24.03379521,  21.15621214,  17.64301199])
colour.set_domain_range_scale("1")

XYZ_1 = [0.2800, 0.2126, 0.0527]
Y_o = 0.2
colour.adaptation.chromatic_adaptation_CIE1994(
    XYZ_1, xy_o1, xy_o2, Y_o, E_o1, E_o2
)
array([ 0.24033795,  0.21156212,  0.17643012])

The output tristimulus values with the ‘1’ domain-range scale are equal to those from ‘Reference’ default domain-range scale divided by 100.

Passing incorrectly scaled values to the colour.adaptation.chromatic_adaptation_CIE1994() definition would result in unexpected values and a warning in that case:

colour.set_domain_range_scale("Reference")

colour.adaptation.chromatic_adaptation_CIE1994(
    XYZ_1, xy_o1, xy_o2, Y_o, E_o1, E_o2
)
File "<ipython-input-...>", line 4, in <module>
  E_o2)
File "/colour-science/colour/colour/adaptation/cie1994.py", line 134, in chromatic_adaptation_CIE1994
  warning(('"Y_o" luminance factor must be in [18, 100] domain, '
/colour-science/colour/colour/utilities/verbose.py:207: ColourWarning: "Y_o" luminance factor must be in [18, 100] domain, unpredictable results may occur!
  warn(*args, **kwargs)
array([ 0.17171825,  0.13731098,  0.09972054])

Setting the ‘1’ domain-range scale has the following effect on the colour.adaptation.chromatic_adaptation_CIE1994() definition:

As it expects values in domain [0, 100], scaling occurs and the relevant input values, i.e. the values listed in the domain table, XYZ_1 and Y_o are converted from domain [0, 1] to domain [0, 100] by colour.utilities.to_domain_100() definition and conversely return value XYZ_2 is converted from range [0, 100] to range [0, 1] by colour.utilities.from_range_100() definition.

A convenient alternative to the colour.set_domain_range_scale() definition is the colour.domain_range_scale context manager and decorator. It temporarily overrides Colour domain-range scale with given scale value:

with colour.domain_range_scale("1"):
    colour.adaptation.chromatic_adaptation_CIE1994(
        XYZ_1, xy_o1, xy_o2, Y_o, E_o1, E_o2
    )
[ 0.24033795  0.21156212  0.17643012]

Multiprocessing on Windows with Domain-Range Scales#

Windows does not have a fork system call, a consequence is that child processes do not necessarily inherit from changes made to global variables.

It has crucial consequences as Colour stores the current domain-range scale into a global variable.

The solution is to define an initialisation definition that defines the scale upon child processes spawning.

The colour.utilities.multiprocessing_pool context manager conveniently performs the required initialisation so that the domain-range scale is propagated appropriately to child processes.

Safe Power and Division#

Colour default handling of fractional power and zero-division occurring during practical applications is managed via varous definitions and context managers.

Safe Power#

NaNs generation occurs when a negative number \(a\) is raised to the fractional power \(p\). This can be avoided using the colour.algebra.spow() definition that raises to the power as follows: \(sign(a) * |a|^p\).

To the extent possible, the colour.algebra.spow() definition has been used throughout the codebase. The default behaviour is controlled with the following definitions:

Safe Division#

NaNs and +/- infs generation occurs when a number \(a\) is divided 0. This can be avoided using the colour.algebra.sdiv() definition. It has been used wherever deemed relevant in the codebase. The default behaviour is controlled with the following definitions:

The following modes are available:

  • Numpy: The current Numpy zero-division handling occurs.

  • Ignore: Zero-division occurs silently.

  • Warning: Zero-division occurs with a warning.

  • Ignore Zero Conversion: Zero-division occurs silently and NaNs or +/- infs values are converted to zeros. See numpy.nan_to_num() definition for more details.

  • Warning Zero Conversion: Zero-division occurs with a warning and NaNs or +/- infs values are converted to zeros. See numpy.nan_to_num() definition for more details.

  • Ignore Limit Conversion: Zero-division occurs silently and NaNs or +/- infs values are converted to zeros or the largest +/- finite floating point values representable by the division result numpy.dtype. See numpy.nan_to_num() definition for more details.

  • Warning Limit Conversion: Zero-division occurs with a warning and NaNs or +/- infs values are converted to zeros or the largest +/- finite floating point values representable by the division result numpy.dtype.

colour.algebra.get_sdiv_mode()
'Ignore Zero Conversion'
colour.algebra.set_sdiv_mode("Numpy")
colour.UCS_to_uv([0, 0, 0])
/Users/kelsolaar/Documents/Development/colour-science/colour/colour/algebra/common.py:317: RuntimeWarning: invalid value encountered in true_divide
  c = a / b
array([ nan,  nan])
colour.algebra.set_sdiv_mode("Ignore Zero Conversion")
colour.UCS_to_uv([0, 0, 0])
array([ 0.,  0.])