Skip to content

Commit

Permalink
Add sRGB to xyY conversion (#51)
Browse files Browse the repository at this point in the history
* Add sRGB to xyY conversion

* add conversion from Kelvin to xyY coordinates

* xy2tradfri utility function

* add tests for kelvin_to_xyY and rgb_to_xyY and simplify existing

* make flake8 happy

* add reverse function xyY_to_kelvin; remove old kelvin functions

* Fix tests

* Raise when kelvin range checks are not met

* Add function set_predefined_color to set predefined colors from Tradfri App

* rgb->xyz conversion with both CIE standard illuminants A and D65

* move kelvin range checking to device class
  • Loading branch information
r41d authored and balloob committed Aug 29, 2017
1 parent 367addb commit ec3bc8e
Show file tree
Hide file tree
Showing 3 changed files with 197 additions and 80 deletions.
162 changes: 124 additions & 38 deletions pytradfri/color.py
Original file line number Diff line number Diff line change
@@ -1,48 +1,134 @@
from .const import (ATTR_LIGHT_COLOR_X as X, ATTR_LIGHT_COLOR_Y as Y)


KNOWN_XY = {
2200: {X: 33135, Y: 27211},
2700: {X: 30140, Y: 26909},
4000: {X: 24930, Y: 24694},
# Kelvin range for which the conversion functions work
# and that RGB bulbs can show
MIN_KELVIN = 1667
MAX_KELVIN = 25000

# Kelvin range that white-spectrum bulbs can actually show
MIN_KELVIN_WS = 2200
MAX_KELVIN_WS = 4000

# Extracted from Tradfri Android App string.xml
COLOR_NAMES = {
'4a418a': 'Blue',
'6c83ba': 'Light Blue',
'8f2686': 'Saturated Purple',
'a9d62b': 'Lime',
'c984bb': 'Light Purple',
'd6e44b': 'Yellow',
'd9337c': 'Saturated Pink',
'da5d41': 'Dark Peach',
'dc4b31': 'Saturated Red',
'dcf0f8': 'Cold sky',
'e491af': 'Pink',
'e57345': 'Peach',
'e78834': 'Warm Amber',
'e8bedd': 'Light Pink',
'eaf6fb': 'Cool daylight',
'ebb63e': 'Candlelight',
'efd275': 'Warm glow',
'f1e0b5': 'Warm white',
'f2eccf': 'Sunrise',
'f5faf6': 'Cool white'
}
KNOWN_KELVIN = KNOWN_XY.keys()
KNOWN_X = [v[X] for v in KNOWN_XY.values()]
MIN_KELVIN = min(KNOWN_KELVIN)
MAX_KELVIN = max(KNOWN_KELVIN)
MIN_X = min(KNOWN_X)
MAX_X = max(KNOWN_X)
# When setting colors by name via the API,
# lowercase strings with no spaces are preferred
COLORS = {name.lower().replace(" ", "_"): hex
for hex, name in COLOR_NAMES.items()}


def can_kelvin_to_xy(k):
return MIN_KELVIN <= k <= MAX_KELVIN


def can_x_to_kelvin(x):
return MIN_X <= x <= MAX_X


def kelvin_to_xy(k):
if k in KNOWN_XY:
return KNOWN_XY[k]
lower_k = max(kk for kk in KNOWN_KELVIN if kk < k)
higher_k = min(kk for kk in KNOWN_KELVIN if kk > k)
offset = (k - lower_k) / (higher_k - lower_k)
lower = KNOWN_XY[lower_k]
higher = KNOWN_XY[higher_k]
return {
coord: int(offset * higher[coord] + (1 - offset) * lower[coord])
for coord in [X, Y]
}


def x_to_kelvin(x):
known = next((k for k, v in KNOWN_XY.items() if v[X] == x), None)
if known is not None:
return known
lower_x = max(k for k in KNOWN_X if k < x)
higher_x = min(k for k in KNOWN_X if k > x)
lower = x_to_kelvin(lower_x)
higher = x_to_kelvin(higher_x)
offset = (x - lower_x) / (higher_x - lower_x)
return int(offset * higher + (1 - offset) * lower)
# Only used locally to perform normalization of x, y values
# Scaling to 65535 range and rounding
def normalize_xy(x, y):
return (int(x*65535+0.5), int(y*65535+0.5))


def kelvin_to_xyY(T, white_spectrum_bulb=False):
# Sources: "Design of Advanced Color - Temperature Control System
# for HDTV Applications" [Lee, Cho, Kim]
# and https://en.wikipedia.org/wiki/Planckian_locus#Approximation
# and http://fcam.garage.maemo.org/apiDocs/_color_8cpp_source.html

# Check for Kelvin range for which this function works
if not (MIN_KELVIN <= T <= MAX_KELVIN):
raise ValueError('Kelvin needs to be between {} and {}'.format(
MIN_KELVIN, MAX_KELVIN))

# Check for White-Spectrum kelvin range
if white_spectrum_bulb and not (MIN_KELVIN_WS <= T <= MAX_KELVIN_WS):
raise ValueError('Kelvin needs to be between {} and {} for '
'white spectrum bulbs'.format(
MIN_KELVIN_WS, MAX_KELVIN_WS))

if T <= 4000:
# One number differs on Wikipedia and the paper:
# 0.2343589 is 0.2343580 on Wikipedia... don't know why
x = -0.2661239*(10**9)/T**3 - 0.2343589*(10**6)/T**2 \
+ 0.8776956*(10**3)/T + 0.17991
elif T <= 25000:
x = -3.0258469*(10**9)/T**3 + 2.1070379*(10**6)/T**2 \
+ 0.2226347*(10**3)/T + 0.24039

if T <= 2222:
y = -1.1063814*x**3 - 1.3481102*x**2 + 2.18555832*x - 0.20219683
elif T <= 4000:
y = -0.9549476*x**3 - 1.37418593*x**2 + 2.09137015*x - 0.16748867
elif T <= 25000:
y = 3.081758*x**3 - 5.8733867*x**2 + 3.75112997*x - 0.37001483

x, y = normalize_xy(x, y)
return {X: x, Y: y}


def xyY_to_kelvin(x, y):
# This is an approximation, for information, see the source.
# Source: https://en.wikipedia.org/wiki/Color_temperature#Approximation
# Input range for x and y is 0-65535
n = (x/65535-0.3320) / (y/65535-0.1858)
kelvin = int((-449*n**3 + 3525*n**2 - 6823.3*n + 5520.33) + 0.5)
return kelvin


def rgb2xyzA(r, g, b):
# Uses CIE standard illuminant A = 2856K
# src: http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html
# calculation https://gist.github.com/r41d/43e14df2ccaeca56d32796efd6584b48
X = 0.76103282*r + 0.29537849*g + 0.04208869*b
Y = 0.39240755*r + 0.59075697*g + 0.01683548*b
Z = 0.03567341*r + 0.0984595*g + 0.22166709*b
return X, Y, Z


def rgb2xyzD65(r, g, b):
# Uses CIE standard illuminant D65 = 6504K
# src: http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html
X = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b
Y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b
Z = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b
return X, Y, Z


def xyz2xyY(X, Y, Z):
total = X + Y + Z
return (0, 0) if total == 0 else normalize_xy(X / total, Y / total)


def rgb_to_xyY(r, g, b):
# According to http://www.brucelindbloom.com/index.html?Eqn_RGB_to_XYZ.html
# and http://www.brucelindbloom.com/index.html?Eqn_XYZ_to_xyY.html
def prepare(val):
val = max(min(val, 255), 0) / 255.0
if val <= 0.04045:
return val / 12.92
else:
return ((val + 0.055) / 1.055) ** 2.4
r, g, b = map(prepare, (r, g, b))

x, y = xyz2xyY(*rgb2xyzA(r, g, b))
return {X: x, Y: y}
24 changes: 20 additions & 4 deletions pytradfri/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
ATTR_LIGHT_COLOR,
ATTR_TRANSITION_TIME
)
from .color import kelvin_to_xy, x_to_kelvin, can_x_to_kelvin
from .color import can_kelvin_to_xy, kelvin_to_xyY, xyY_to_kelvin, rgb_to_xyY,\
COLORS
from .resource import ApiResource


Expand Down Expand Up @@ -176,7 +177,18 @@ def set_xy_color(self, color_x, color_y, *, index=0):
}, index=index)

def set_kelvin_color(self, kelvins, *, index=0):
return self.set_values(kelvin_to_xy(kelvins), index=index)
return self.set_values(kelvin_to_xyY(kelvins), index=index)

def set_predefined_color(self, colorname, *, index=0):
try:
color = COLORS[colorname.lower().replace(" ", "_")]
except:
pass
else:
return self.set_hex_color(color, index=index)

def set_rgb_color(self, r, g, b, *, index=0):
return self.set_values(rgb_to_xyY(r, g, b), index=index)

def set_values(self, values, *, index=0):
"""
Expand Down Expand Up @@ -228,8 +240,12 @@ def xy_color(self):
@property
def kelvin_color(self):
current_x = self.raw.get(ATTR_LIGHT_COLOR_X)
if current_x is not None and can_x_to_kelvin(current_x):
return x_to_kelvin(current_x)
current_y = self.raw.get(ATTR_LIGHT_COLOR_Y)
if current_x is not None and current_y is not None:
kelvin = xyY_to_kelvin(current_x, current_y)
# Only return a kelvin value if it is inside the range that the
# kelvin->xyY function supports
return kelvin if can_kelvin_to_xy(kelvin) else None

@property
def raw(self):
Expand Down
91 changes: 53 additions & 38 deletions tests/test_color.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,65 @@
from pytradfri.color import kelvin_to_xy, x_to_kelvin, \
can_kelvin_to_xy, can_x_to_kelvin
from pytradfri.const import (ATTR_LIGHT_COLOR_X as X, ATTR_LIGHT_COLOR_Y as Y)
from pytradfri.color import can_kelvin_to_xy, kelvin_to_xyY, xyY_to_kelvin, \
rgb_to_xyY


def test_known_warm():
assert kelvin_to_xy(2200) == {'5709': 33135, '5710': 27211}
assert x_to_kelvin(33135) == 2200
def test_can_dekelvinize():
assert can_kelvin_to_xy(1600) is False
assert can_kelvin_to_xy(1800) is True
assert can_kelvin_to_xy(2000) is True
assert can_kelvin_to_xy(2200) is True
assert can_kelvin_to_xy(2400) is True
assert can_kelvin_to_xy(2700) is True
assert can_kelvin_to_xy(3000) is True
assert can_kelvin_to_xy(4000) is True
assert can_kelvin_to_xy(5000) is True
assert can_kelvin_to_xy(25000) is True
assert can_kelvin_to_xy(26000) is False


def test_known_norm():
assert kelvin_to_xy(2700) == {'5709': 30140, '5710': 26909}
assert x_to_kelvin(30140) == 2700
def test_kelvin_to_xyY():
# kelvin_to_xyY approximates, so +-50 is sufficiently precise.
# Values taken from Tradfri App, these only differ slightly from online
# calculators such as https://www.ledtuning.nl/en/cie-convertor

warm = kelvin_to_xyY(2200)
assert warm[X] in range(33135-50, 33135+51)
assert warm[Y] in range(27211-50, 27211+51)

def test_known_cold():
assert kelvin_to_xy(4000) == {'5709': 24930, '5710': 24694}
assert x_to_kelvin(24930) == 4000
normal = kelvin_to_xyY(2700)
assert normal[X] in range(30140-50, 30140+51)
assert normal[Y] in range(26909-50, 26909+51)

cold = kelvin_to_xyY(4000)
assert cold[X] in range(24930-50, 24930+51)
assert cold[Y] in range(24694-50, 24694+51)

def test_unknown_warmish():
assert kelvin_to_xy((2200 + 2700) // 2) == {
'5709': (33135 + 30140) // 2,
'5710': (27211 + 26909) // 2
}
assert x_to_kelvin((33135 + 30140) // 2) == (2200 + 2700) // 2

def test_xyY_to_kelvin():
# xyY_to_kelvin approximates, so +-20 is sufficiently precise.
# Values taken from Tradfri App.
warm = xyY_to_kelvin(33135, 27211)
assert warm in range(2200-20, 2200+21)

def test_unknown_coldish():
assert kelvin_to_xy((2700 + 4000) // 2) == {
'5709': (30140 + 24930) // 2,
'5710': (26909 + 24694) // 2
}
assert x_to_kelvin((30140 + 24930) // 2) == (2700 + 4000) // 2
normal = xyY_to_kelvin(30140, 26909)
assert normal in range(2700-20, 2700+21)

cold = xyY_to_kelvin(24930, 24694)
assert cold in range(4000-20, 4000+21)

def test_can_dekelvinize():
assert can_kelvin_to_xy(2000) is False
assert can_kelvin_to_xy(2200) is True
assert can_kelvin_to_xy(2400) is True
assert can_kelvin_to_xy(2700) is True
assert can_kelvin_to_xy(3000) is True
assert can_kelvin_to_xy(4000) is True
assert can_kelvin_to_xy(5000) is False
assert can_x_to_kelvin(24000) is False
assert can_x_to_kelvin(24930) is True
assert can_x_to_kelvin(26000) is True
assert can_x_to_kelvin(30140) is True
assert can_x_to_kelvin(32000) is True
assert can_x_to_kelvin(33135) is True
assert can_x_to_kelvin(34000) is False

def test_rgb_to_xyY():
# rgb_to_xyY approximates, so +-50 is sufficiently precise.
# Verification values calculated by http://colormine.org/convert/rgb-to-xyz

red = rgb_to_xyY(255, 0, 0)
assert red[X] in range(41947-50, 41947+51)
assert red[Y] in range(21625-50, 21625+51)

green = rgb_to_xyY(0, 255, 0)
assert green[X] in range(19661-50, 19661+51)
assert green[Y] in range(39321-50, 39321+51)

blue = rgb_to_xyY(0, 0, 255)
assert blue[X] in range(9831-50, 9831+51)
assert blue[Y] in range(3933-50, 3933+51)

0 comments on commit ec3bc8e

Please sign in to comment.