-
Notifications
You must be signed in to change notification settings - Fork 130
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
Showing
3 changed files
with
197 additions
and
80 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |