Packages
A comprehensive color library: 21 color spaces, chromatic adaptation, ICC rendering intents, ΔE2000 / WCAG / APCA contrast, gamut mapping, color mixing and gradients, blend modes, color harmonies, color temperature, spectral pipeline, and a full CSS Color 4 / 5 parser. Zero runtime dependencies.
Current section
Files
Jump to
Current section
Files
lib/color/conversion/tristimulus.ex
defmodule Color.Tristimulus do
@moduledoc """
Authoritative source of illuminant reference white data.
This module is the single source of truth for every CIE illuminant
the library supports — `:A`, `:B`, `:C`, `:D50`, `:D55`, `:D65`,
`:D75`, `:E`, `:F2`, `:F7`, `:F11`, plus the LED, ID and DCI series
— and for the two CIE standard observer angles (`2` and `10`). It
is consulted by:
* `Color.XYZ.adapt/3` — chromatic adaptation between illuminants.
* `Color.RGB.WorkingSpace` — when computing the RGB→XYZ matrix for
a working space, the illuminant's reference white feeds the
Lindbloom primary-scaling step.
* `Color.Lab`, `Color.Luv`, `Color.LCHab`, `Color.LCHuv`,
`Color.XyY` — every CIE-tagged colour space converts to XYZ
relative to its illuminant's reference white.
* `Color.Spectral` — the integration normaliser uses the
illuminant SPDs from `Color.Spectral.Tables`, which are paired
with the chromaticities defined here.
Reference whites for the 11 most common illuminants at the 2°
observer come directly from Bruce Lindbloom's tables and are
reproduced exactly. The remaining illuminants are computed from
CIE 1931 / CIE 1964 chromaticities.
All values are returned as plain lists or tuples of `float()`
— the module has no `Nx` dependency.
"""
@observer_angles [2, 10]
@doc """
Returns the supported CIE standard observer angles.
### Examples
iex> Color.Tristimulus.observer_angles()
[2, 10]
"""
@spec observer_angles() :: [2 | 10]
def observer_angles do
@observer_angles
end
@doc """
Returns the reference white for an illuminant as a three-element list
`[Xr, Yr, Zr]` of floats.
### Arguments
* `options` is a keyword list.
### Options
* `:illuminant` defaults to `:D65`.
* `:observer_angle` defaults to `2`.
### Returns
* A list of three floats.
"""
def reference_white(options \\ []) when is_list(options) do
illuminant = Keyword.get(options, :illuminant, :D65)
observer_angle = Keyword.get(options, :observer_angle, 2)
case tristimulus(illuminant, observer_angle) do
{:ok, data} -> data
{:error, exception} -> raise exception
end
end
@doc """
Returns the reference white for an illuminant as an `{Xr, Yr, Zr}` tuple
of plain floats, suitable for feeding into `Color.Conversion.Lindbloom`.
### Arguments
* `options` is a keyword list.
### Options
* `:illuminant` is the illuminant atom (for example `:D65`, `:D50`).
Defaults to `:D65`.
* `:observer_angle` is the observer angle in degrees, `2` or `10`.
Defaults to `2`.
### Returns
* An `{Xr, Yr, Zr}` tuple of floats.
### Examples
iex> Color.Tristimulus.reference_white_tuple(illuminant: :D65)
{0.95047, 1.0, 1.08883}
"""
def reference_white_tuple(options \\ []) when is_list(options) do
[x, y, z] = reference_white(options)
{x, y, z}
end
@xyz_tristimulus_table """
# 2° (CIE 1931) 10° (CIE 1964)
# Illuminant x2 y2 x10 y10 CCT Description
A 0.44757 0.40745 0.45117 0.40594 2856 # incandescent / tungsten
B 0.34842 0.35161 0.34980 0.35270 4874 # obsolete, direct sunlight at noon
C 0.31006 0.31616 0.31039 0.31905 6774 # obsolete, average / North sky daylight
D50 0.34567 0.35850 0.34773 0.35952 5003 # horizon light, ICC profile PCS
D55 0.33242 0.34743 0.33411 0.34877 5503 # mid-morning / mid-afternoon daylight
D65 0.31271 0.32902 0.31382 0.33100 6504 # noon daylight: television, sRGB color space
D75 0.29902 0.31485 0.29968 0.31740 7504 # North sky daylight
D93 0.28315 0.29711 0.28327 0.30043 9305 # high-efficiency blue phosphor monitors, BT.2035
E 0.33333 0.33333 0.33333 0.33333 5454 # equal energy
F1 0.31310 0.33727 0.31811 0.33559 6430 # daylight fluorescent
F2 0.37208 0.37529 0.37925 0.36733 4230 # cool white fluorescent
F3 0.40910 0.39430 0.41761 0.38324 3450 # white fluorescent
F4 0.44018 0.40329 0.44920 0.39074 2940 # warm white fluorescent
F5 0.31379 0.34531 0.31975 0.34246 6350 # daylight fluorescent
F6 0.37790 0.38835 0.38660 0.37847 4150 # light white fluorescent
F7 0.31292 0.32933 0.31569 0.32960 6500 # D65 simulator, daylight simulator
F8 0.34588 0.35875 0.34902 0.35939 5000 # D50 simulator, Sylvania F40 Design 50
F9 0.37417 0.37281 0.37829 0.37045 4150 # cool white deluxe fluorescent
F10 0.34609 0.35986 0.35090 0.35444 5000 # Philips TL85, Ultralume 50
F11 0.38052 0.37713 0.38541 0.37123 4000 # Philips TL84, Ultralume 40
F12 0.43695 0.40441 0.44256 0.39717 3000 # Philips TL83, Ultralume 30
LED_B1 0.4560 0.4078 _ _ 2733 # phosphor_converted blue
LED_B2 0.4357 0.4012 _ _ 2998 # phosphor_converted blue
LED_B3 0.3756 0.3723 _ _ 4103 # phosphor_converted blue
LED_B4 0.3422 0.3502 _ _ 5109 # phosphor_converted blue
LED_B5 0.3118 0.3236 _ _ 6598 # phosphor_converted blue
LED_BH1 0.4474 0.4066 _ _ 2851 # mixing of phosphor_converted blue LED and red LED (blue_hybrid)
LED_RGB1 0.4557 0.4211 _ _ 2840 # mixing of red, green, and blue LEDs
LED_V1 0.4560 0.4548 _ _ 2724 # phosphor_converted violet
LED_V2 0.3781 0.3775 _ _ 4070 # phosphor-converted violet
# For the following illuminants, the 10° chomaticity comes from the Python `colour_science` library
# CIE 184:2009 Indoor Daylight Illuminants
# https://www.researchgate.net/publication/328812649_CIE_1842009_Indoor_Daylight_Illuminants
ID65 0.3106 0.3306 0.31207 0.33266 6504 # indoor daylight d65
ID50 0.3432 0.3602 0.34562 0.36122 5003 # indoor daylight d50
# From TB-2018-001 Derivation of the ACES white point CIE chromaticity coordinates
D60 0.32168 0.33767 0.32298 0.33927 5998 # Academy of Motion Picture Arts and Sciences
# DCI P3 https://en.wikipedia.org/wiki/DCI-P3
DCI 0.314 0.351 _ _ 6300 # Non-CIE Illuminant
"""
@illuminant_aliases %{
P3_D65: :D65,
Rec709: :D65,
Rec2020: :D65,
Cinema_Gamut: :D65,
P3_DCI_P: :DCI,
P3_DCI: :DCI,
DCI_P: :DCI,
ACES: :D60
}
@illumimant_alias_names Map.keys(@illuminant_aliases)
@illuminants @xyz_tristimulus_table
|> String.split("\n", trim: true)
|> Enum.reject(&String.starts_with?(&1, "#"))
|> Enum.map(fn line ->
line
|> String.split("\s", trim: true)
|> hd
|> String.to_atom()
end)
def illuminants do
@illuminants
end
# Canonical XYZ values published by Bruce Lindbloom
# (http://www.brucelindbloom.com/index.html?WorkingSpaceInfo.html).
# These are authoritative for the 2° observer and are used in place of
# values computed from the chromaticity table so that sRGB and other
# Lindbloom-based math reproduces his published numbers exactly.
@lindbloom_overrides_2 %{
A: [1.09850, 1.00000, 0.35585],
B: [0.99072, 1.00000, 0.85223],
C: [0.98074, 1.00000, 1.18232],
D50: [0.96422, 1.00000, 0.82521],
D55: [0.95682, 1.00000, 0.92149],
D65: [0.95047, 1.00000, 1.08883],
D75: [0.94972, 1.00000, 1.22638],
E: [1.00000, 1.00000, 1.00000],
F2: [0.99186, 1.00000, 0.67393],
F7: [0.95041, 1.00000, 1.08747],
F11: [1.00962, 1.00000, 0.64350]
}
@xyz_tristimulus @xyz_tristimulus_table
|> String.split("\n", trim: true)
|> Enum.reject(&String.starts_with?(&1, "#"))
|> Enum.flat_map(fn line ->
[illuminant | data] =
line
|> String.split("#")
|> hd()
|> String.split("\s", trim: true)
|> Enum.map(fn elem ->
case Float.parse(elem) do
{float, ""} -> float
:error -> elem
end
end)
[cie1931, cie1964, _cct] = Enum.chunk_every(data, 2)
atom = String.to_atom(illuminant)
cie1931 =
case Map.fetch(@lindbloom_overrides_2, atom) do
{:ok, xyz} -> xyz
:error -> Color.XyY.to_xyz_list(cie1931)
end
cie1964 = Color.XyY.to_xyz_list(cie1964)
case Enum.reject([cie1931, cie1964], &is_nil/1) do
[cie1931] ->
[{{atom, 2}, cie1931}]
[cie1931, cie1964] ->
[
{{atom, 2}, cie1931},
{{atom, 10}, cie1964}
]
end
end)
|> Map.new()
def tristimulus do
@xyz_tristimulus
end
def tristimulus(illuminant, observer_angle) do
with {:ok, illuminant} <- validate_illuminant(illuminant),
{:ok, observer_angle} <- validate_observer_angle(observer_angle) do
case Map.fetch(tristimulus(), {illuminant, observer_angle}) do
{:ok, tristimulus} ->
{:ok, tristimulus}
_other ->
{:error,
%Color.UnknownIlluminantError{
illuminant: illuminant,
observer_angle: observer_angle,
valid: @illuminants
}}
end
end
end
def validate_illuminant(illuminant) when illuminant in @illuminants do
{:ok, illuminant}
end
def validate_illuminant(illuminant) when illuminant in @illumimant_alias_names do
{:ok, Map.fetch!(@illuminant_aliases, illuminant)}
end
def validate_illuminant(illuminant) when is_binary(illuminant) do
illuminant
|> String.upcase()
|> String.replace(["-", " "], "_")
|> String.to_existing_atom()
|> validate_illuminant
rescue
ArgumentError ->
{:error, %Color.UnknownIlluminantError{illuminant: illuminant, valid: @illuminants}}
end
def validate_illuminant(illuminant) do
{:error, %Color.UnknownIlluminantError{illuminant: illuminant, valid: @illuminants}}
end
def validate_observer_angle(observer_angle) when observer_angle in @observer_angles do
{:ok, observer_angle}
end
def validate_observer_angle(observer_angle) do
{:error,
%Color.InvalidComponentError{
space: "Tristimulus",
value: observer_angle,
reason: :out_of_range
}}
end
end