543 lines
20 KiB
Python
543 lines
20 KiB
Python
# The MIT License (MIT)
|
|
#
|
|
# Copyright (c) 2017 PaintYourDragon for Adafruit Industries
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
# in the Software without restriction, including without limitation the rights
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be included in
|
|
# all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
# THE SOFTWARE.
|
|
"""
|
|
`adafruit_fancyled.adafruit_fancyled`
|
|
====================================================
|
|
|
|
FancyLED is a CircuitPython library to assist in creating buttery smooth LED animation.
|
|
It's loosely inspired by the FastLED library for Arduino, and in fact we have a "helper"
|
|
library using similar function names to assist with porting of existing Arduino FastLED
|
|
projects to CircuitPython.
|
|
|
|
* Author(s): PaintYourDragon
|
|
"""
|
|
|
|
# imports
|
|
|
|
__version__ = "0.0.0-auto.0"
|
|
__repo__ = "https://github.com/Adafruit/Adafruit_CircuitPython_FancyLED.git"
|
|
|
|
from math import floor
|
|
|
|
|
|
# FancyLED provides color- and palette-related utilities for LED projects,
|
|
# offering a buttery smooth look instead of the usual 8-bit-like "blip blip"
|
|
# effects often seen with LEDs. It's loosely inspired by, but NOT a drop-in
|
|
# replacement for, the FastLED library for Arduino.
|
|
|
|
|
|
class CRGB:
|
|
"""Color stored in Red, Green, Blue color space.
|
|
|
|
One of two ways: separate red, gren, blue values (either as integers
|
|
(0 to 255 range) or floats (0.0 to 1.0 range), either type is
|
|
'clamped' to valid range and stored internally in the normalized
|
|
(float) format), OR can accept a CHSV color as input, which will be
|
|
converted and stored in RGB format.
|
|
|
|
Following statements are equivalent - all return red:
|
|
|
|
.. code-block:: python
|
|
|
|
c = CRGB(255, 0, 0)
|
|
c = CRGB(1.0, 0.0, 0.0)
|
|
c = CRGB(CHSV(0.0, 1.0, 1.0))
|
|
"""
|
|
|
|
def __init__(self, red, green=0.0, blue=0.0):
|
|
# pylint: disable=too-many-branches
|
|
if isinstance(red, CHSV):
|
|
# If first/only argument is a CHSV type, perform HSV to RGB
|
|
# conversion.
|
|
hsv = red # 'red' is CHSV, this is just more readable
|
|
hue = hsv.hue * 6.0 # Hue circle = 0.0 to 6.0
|
|
sxt = floor(hue) # Sextant index is next-lower integer of hue
|
|
frac = hue - sxt # Fraction-within-sextant is 0.0 to <1.0
|
|
sxt = int(sxt) % 6 # mod6 the sextant so it's always 0 to 5
|
|
|
|
if sxt == 0: # Red to <yellow
|
|
r, g, b = 1.0, frac, 0.0
|
|
elif sxt == 1: # Yellow to <green
|
|
r, g, b = 1.0 - frac, 1.0, 0.0
|
|
elif sxt == 2: # Green to <cyan
|
|
r, g, b = 0.0, 1.0, frac
|
|
elif sxt == 3: # Cyan to <blue
|
|
r, g, b = 0.0, 1.0 - frac, 1.0
|
|
elif sxt == 4: # Blue to <magenta
|
|
r, g, b = frac, 0.0, 1.0
|
|
else: # Magenta to <red
|
|
r, g, b = 1.0, 0.0, 1.0 - frac
|
|
|
|
invsat = 1.0 - hsv.saturation # Inverse-of-saturation
|
|
|
|
self.red = ((r * hsv.saturation) + invsat) * hsv.value
|
|
self.green = ((g * hsv.saturation) + invsat) * hsv.value
|
|
self.blue = ((b * hsv.saturation) + invsat) * hsv.value
|
|
else:
|
|
# Red, green, blue arguments (normalized floats OR integers)
|
|
# TODO(tannewt): Factor this out into a helper function
|
|
if isinstance(red, float):
|
|
self.red = clamp(red, 0.0, 1.0)
|
|
else:
|
|
self.red = normalize(red)
|
|
if isinstance(green, float):
|
|
self.green = clamp(green, 0.0, 1.0)
|
|
else:
|
|
self.green = normalize(green)
|
|
if isinstance(blue, float):
|
|
self.blue = clamp(blue, 0.0, 1.0)
|
|
else:
|
|
self.blue = normalize(blue)
|
|
|
|
def __repr__(self): # pylint: disable=invalid-repr-returned
|
|
return (self.red, self.green, self.blue)
|
|
|
|
def __str__(self):
|
|
return "(%s, %s, %s)" % (self.red, self.green, self.blue)
|
|
|
|
def __len__(self):
|
|
"""Retrieve total number of color-parts available."""
|
|
return 3
|
|
|
|
def __getitem__(self, key):
|
|
"""Retrieve red, green or blue value as iterable."""
|
|
if key == 0:
|
|
return self.red
|
|
if key == 1:
|
|
return self.green
|
|
if key == 2:
|
|
return self.blue
|
|
raise IndexError
|
|
|
|
def pack(self):
|
|
"""'Pack' a `CRGB` color into a 24-bit RGB integer.
|
|
|
|
:returns: 24-bit integer a la ``0x00RRGGBB``.
|
|
"""
|
|
|
|
return (
|
|
(denormalize(self.red) << 16)
|
|
| (denormalize(self.green) << 8)
|
|
| (denormalize(self.blue))
|
|
)
|
|
|
|
|
|
class CHSV:
|
|
"""Color stored in Hue, Saturation, Value color space.
|
|
|
|
Accepts hue as float (any range) or integer (0-256 -> 0.0-1.0) with
|
|
no clamping performed (hue can 'wrap around'), saturation and value
|
|
as float (0.0 to 1.0) or integer (0 to 255), both are clamped and
|
|
stored internally in the normalized (float) format. Latter two are
|
|
optional, can pass juse hue and saturation/value will default to 1.0.
|
|
|
|
Unlike `CRGB` (which can take a `CHSV` as input), there's currently
|
|
no equivalent RGB-to-HSV conversion, mostly because it's a bit like
|
|
trying to reverse a hash...there may be multiple HSV solutions for a
|
|
given RGB input.
|
|
|
|
This might be OK as long as conversion precedence is documented,
|
|
but otherwise (and maybe still) could cause confusion as certain
|
|
HSV->RGB->HSV translations won't have the same input and output.
|
|
"""
|
|
|
|
def __init__(self, h, s=1.0, v=1.0):
|
|
if isinstance(h, float):
|
|
self.hue = h # Don't clamp! Hue can wrap around forever.
|
|
else:
|
|
self.hue = float(h) / 256.0
|
|
if isinstance(s, float):
|
|
self.saturation = clamp(s, 0.0, 1.0)
|
|
else:
|
|
self.saturation = normalize(s)
|
|
if isinstance(v, float):
|
|
self.value = clamp(v, 0.0, 1.0)
|
|
else:
|
|
self.value = normalize(v)
|
|
|
|
def __repr__(self): # pylint: disable=invalid-repr-returned
|
|
return (self.hue, self.saturation, self.value)
|
|
|
|
def __str__(self):
|
|
return "(%s, %s, %s)" % (self.hue, self.saturation, self.value)
|
|
|
|
def __len__(self):
|
|
"""Retrieve total number of 'color-parts' available."""
|
|
return 3
|
|
|
|
def __getitem__(self, key):
|
|
"""Retrieve hue, saturation or value as iterable."""
|
|
if key == 0:
|
|
return self.hue
|
|
if key == 1:
|
|
return self.saturation
|
|
if key == 2:
|
|
return self.value
|
|
raise IndexError
|
|
|
|
def pack(self):
|
|
"""'Pack' a `CHSV` color into a 24-bit RGB integer.
|
|
|
|
:returns: 24-bit integer a la ``0x00RRGGBB``.
|
|
"""
|
|
|
|
# Convert CHSV to CRGB, return packed result
|
|
return CRGB(self).pack()
|
|
|
|
|
|
def clamp(val, lower, upper):
|
|
"""Constrain value within a numeric range (inclusive).
|
|
"""
|
|
return max(lower, min(val, upper))
|
|
|
|
|
|
def normalize(val, inplace=False):
|
|
"""Convert 8-bit (0 to 255) value to normalized (0.0 to 1.0) value.
|
|
|
|
Accepts integer, 0 to 255 range (input is clamped) or a list or tuple
|
|
of integers. In list case, 'inplace' can be used to control whether
|
|
the original list is modified (True) or a new list is generated and
|
|
returned (False).
|
|
|
|
Returns float, 0.0 to 1.0 range, or list of floats (or None if inplace).
|
|
"""
|
|
|
|
if isinstance(val, int):
|
|
# Divide by 255 (not 256) so maximum level is 1.0.
|
|
return clamp(val, 0, 255) / 255.0
|
|
|
|
# If not int, is assumed list or tuple.
|
|
if inplace:
|
|
# Modify list in-place (OK for lists, NOT tuples, no check made)
|
|
for i, n in enumerate(val):
|
|
val[i] = normalize(n)
|
|
return None
|
|
|
|
# Generate new list
|
|
return [normalize(n) for n in val]
|
|
|
|
|
|
def denormalize(val, inplace=False):
|
|
"""Convert normalized (0.0 to 1.0) value to 8-bit (0 to 255) value
|
|
|
|
Accepts float, 0.0 to 1.0 range or a list or tuple of floats. In
|
|
list case, 'inplace' can be used to control whether the original list
|
|
is modified (True) or a new list is generated and returned (False).
|
|
|
|
Returns integer, 0 to 255 range, or list of integers (or None if
|
|
inplace).
|
|
"""
|
|
|
|
# 'Denormalizing' math varies slightly from normalize(). This is on
|
|
# purpose. Multiply by 256 (NOT 255) and clip. This ensures that all
|
|
# fractional values fall into the correct 'buckets' -- e.g. 0.999
|
|
# should return 255, not 254 -- and that the buckets are all equal-
|
|
# sized (usu. method of adding 0.5 before int() would miss this).
|
|
if isinstance(val, float):
|
|
return clamp(int(val * 256.0), 0, 255)
|
|
|
|
# If not int, is assumed list or tuple.
|
|
if inplace:
|
|
# Modify the list in-place (OK for lists, NOT tuples, no check made)
|
|
for i, n in enumerate(val):
|
|
val[i] = denormalize(n)
|
|
return None
|
|
|
|
# Generate new list
|
|
return [denormalize(n) for n in val]
|
|
|
|
|
|
def unpack(val):
|
|
"""'Unpack' a 24-bit color into a `CRGB` instance.
|
|
|
|
:param int val: 24-bit integer a la ``0x00RRGGBB``.
|
|
:returns: CRGB color.
|
|
:rtype: CRGB
|
|
"""
|
|
|
|
# See notes in normalize() for math explanation. Large constants here
|
|
# avoid the usual shift-right step, e.g. 16711680.0 is 255 * 256 * 256,
|
|
# so we can just mask out the red and divide by this for 0.0 to 1.0.
|
|
return CRGB(
|
|
(val & 0xFF0000) / 16711680.0, # Red
|
|
(val & 0x00FF00) / 65280.0, # Green
|
|
(val & 0x0000FF) / 255.0,
|
|
) # Blue
|
|
|
|
|
|
def mix(color1, color2, weight2=0.5):
|
|
"""Blend between two colors using given ratio. Accepts two colors (each
|
|
may be `CRGB`, `CHSV` or packed integer), and weighting (0.0 to 1.0)
|
|
of second color.
|
|
|
|
:returns: `CRGB` color in most cases, `CHSV` if both inputs are `CHSV`.
|
|
"""
|
|
|
|
clamp(weight2, 0.0, 1.0)
|
|
weight1 = 1.0 - weight2
|
|
|
|
if isinstance(color1, CHSV):
|
|
if isinstance(color2, CHSV):
|
|
# Both colors are CHSV -- interpolate in HSV color space
|
|
# because of the way hue can cross the unit boundary...
|
|
# e.g. if the hues are 0.25 and 0.75, the center point is
|
|
# 0.5 (cyan)...but if you want hues to wrap the other way
|
|
# (with red at the center), you can have hues of 1.25 and 0.75.
|
|
hue = color1.hue + ((color2.hue - color1.hue) * weight2)
|
|
sat = color1.saturation * weight1 + color2.saturation * weight2
|
|
val = color1.value * weight1 + color2.value * weight2
|
|
return CHSV(hue, sat, val)
|
|
# Else color1 is HSV, color2 is RGB. Convert color1 to RGB
|
|
# before doing interpolation in RGB space.
|
|
color1 = CRGB(color1)
|
|
# If color2 is a packed integer, convert to CRGB instance.
|
|
if isinstance(color2, int):
|
|
color2 = unpack(color2)
|
|
else:
|
|
if isinstance(color2, CHSV):
|
|
# color1 is RGB, color2 is HSV. Convert color2 to RGB
|
|
# before interpolating in RGB space.
|
|
color2 = CRGB(color2)
|
|
elif isinstance(color2, int):
|
|
# If color2 is a packed integer, convert to CRGB instance.
|
|
color2 = unpack(color2)
|
|
# If color1 is a packed integer, convert to CRGB instance.
|
|
if isinstance(color1, int):
|
|
color1 = unpack(color1)
|
|
|
|
# Interpolate and return as CRGB type
|
|
return CRGB(
|
|
(color1.red * weight1 + color2.red * weight2),
|
|
(color1.green * weight1 + color2.green * weight2),
|
|
(color1.blue * weight1 + color2.blue * weight2),
|
|
)
|
|
|
|
|
|
GFACTOR = 2.7 # Default gamma-correction factor for function below
|
|
|
|
|
|
def gamma_adjust(val, gamma_value=None, brightness=1.0, inplace=False):
|
|
"""Provides gamma adjustment for single values, `CRGB` and `CHSV` types
|
|
and lists of any of these.
|
|
|
|
Works in one of three ways:
|
|
1. Accepts a single normalized level (0.0 to 1.0) and optional
|
|
gamma-adjustment factor (float usu. > 1.0, default if
|
|
unspecified is GFACTOR) and brightness (float 0.0 to 1.0,
|
|
default is 1.0). Returns a single normalized gamma-corrected
|
|
brightness level (0.0 to 1.0).
|
|
2. Accepts a single `CRGB` or `CHSV` type, optional single gamma
|
|
factor OR a (R,G,B) gamma tuple (3 values usu. > 1.0), optional
|
|
single brightness factor OR a (R,G,B) brightness tuple. The
|
|
input tuples are RGB even when a `CHSV` color is passed. Returns
|
|
a normalized gamma-corrected `CRGB` type (NOT `CHSV`!).
|
|
3. Accept a list or tuple of normalized levels, `CRGB` or `CHSV`
|
|
types (and optional gamma and brightness levels or tuples
|
|
applied to all). Returns a list of gamma-corrected values or
|
|
`CRGB` types (NOT `CHSV`!).
|
|
|
|
In cases 2 and 3, if the input is a list (NOT a tuple!), the 'inplace'
|
|
flag determines whether a new tuple/list is calculated and returned,
|
|
or the existing value is modified in-place. By default this is
|
|
'False'. If you try to inplace-modify a tuple, an exception is raised.
|
|
|
|
In cases 2 and 3, there is NO return value if 'inplace' is True --
|
|
the original values are modified.
|
|
"""
|
|
# pylint: disable=too-many-branches
|
|
|
|
if isinstance(val, float):
|
|
# Input value appears to be a single float
|
|
if gamma_value is None:
|
|
gamma_value = GFACTOR
|
|
return pow(val, gamma_value) * brightness
|
|
|
|
if isinstance(val, (list, tuple)):
|
|
# List or tuple of values
|
|
if isinstance(val[0], float):
|
|
# Input appears to be a list of floats
|
|
if gamma_value is None:
|
|
gamma_value = GFACTOR
|
|
if inplace:
|
|
for i, x in enumerate(val):
|
|
val[i] = pow(val[i], gamma_value) * brightness
|
|
return None
|
|
newlist = []
|
|
for x in val:
|
|
newlist.append(pow(x, gamma_value) * brightness)
|
|
return newlist
|
|
# List of CRGB or CHSV...we'll get back to that in a moment...
|
|
# but first determine gamma-correction factors for R,G,B:
|
|
if gamma_value is None:
|
|
# No gamma specified, use default
|
|
gamma_red, gamma_green, gamma_blue = GFACTOR, GFACTOR, GFACTOR
|
|
elif isinstance(gamma_value, float):
|
|
# Single gamma value provided, apply to R,G,B
|
|
gamma_red, gamma_green, gamma_blue = (gamma_value, gamma_value, gamma_value)
|
|
else:
|
|
gamma_red, gamma_green, gamma_blue = (
|
|
gamma_value[0],
|
|
gamma_value[1],
|
|
gamma_value[2],
|
|
)
|
|
if isinstance(brightness, float):
|
|
# Single brightness value provided, apply to R,G,B
|
|
brightness_red, brightness_green, brightness_blue = (
|
|
brightness,
|
|
brightness,
|
|
brightness,
|
|
)
|
|
else:
|
|
brightness_red, brightness_green, brightness_blue = (
|
|
brightness[0],
|
|
brightness[1],
|
|
brightness[2],
|
|
)
|
|
if inplace:
|
|
for i, x in enumerate(val):
|
|
if isinstance(x, CHSV):
|
|
x = CRGB(x)
|
|
val[i] = CRGB(
|
|
pow(x.red, gamma_red) * brightness_red,
|
|
pow(x.green, gamma_green) * brightness_green,
|
|
pow(x.blue, gamma_blue) * brightness_blue,
|
|
)
|
|
return None
|
|
newlist = []
|
|
for x in val:
|
|
if isinstance(x, CHSV):
|
|
x = CRGB(x)
|
|
newlist.append(
|
|
CRGB(
|
|
pow(x.red, gamma_red) * brightness_red,
|
|
pow(x.green, gamma_green) * brightness_green,
|
|
pow(x.blue, gamma_blue) * brightness_blue,
|
|
)
|
|
)
|
|
return newlist
|
|
|
|
# Single CRGB or CHSV value
|
|
if gamma_value is None:
|
|
# No gamma specified, use default
|
|
gamma_red, gamma_green, gamma_blue = GFACTOR, GFACTOR, GFACTOR
|
|
elif isinstance(gamma_value, float):
|
|
# Single gamma value provided, apply to R,G,B
|
|
gamma_red, gamma_green, gamma_blue = (gamma_value, gamma_value, gamma_value)
|
|
else:
|
|
gamma_red, gamma_green, gamma_blue = (
|
|
gamma_value[0],
|
|
gamma_value[1],
|
|
gamma_value[2],
|
|
)
|
|
if isinstance(brightness, float):
|
|
# Single brightness value provided, apply to R,G,B
|
|
brightness_red, brightness_green, brightness_blue = (
|
|
brightness,
|
|
brightness,
|
|
brightness,
|
|
)
|
|
else:
|
|
brightness_red, brightness_green, brightness_blue = (
|
|
brightness[0],
|
|
brightness[1],
|
|
brightness[2],
|
|
)
|
|
|
|
if isinstance(val, CHSV):
|
|
val = CRGB(val)
|
|
|
|
return CRGB(
|
|
pow(val.red, gamma_red) * brightness_red,
|
|
pow(val.green, gamma_green) * brightness_green,
|
|
pow(val.blue, gamma_blue) * brightness_blue,
|
|
)
|
|
|
|
|
|
def palette_lookup(palette, position):
|
|
"""Fetch color from color palette, with interpolation.
|
|
|
|
:param palette: color palette (list of CRGB, CHSV and/or packed integers)
|
|
:param float position: palette position (0.0 to 1.0, wraps around).
|
|
|
|
:returns: `CRGB` or `CHSV` instance, no gamma correction applied.
|
|
"""
|
|
|
|
position %= 1.0 # Wrap palette position in 0.0 to <1.0 range
|
|
|
|
weight2 = position * len(palette) # Scale position to palette length
|
|
idx = int(floor(weight2)) # Index of 'lower' color (0 to len-1)
|
|
weight2 -= idx # Weighting of 'upper' color
|
|
|
|
color1 = palette[idx] # Fetch 'lower' color
|
|
idx = (idx + 1) % len(palette) # Get index of 'upper' color
|
|
color2 = palette[idx] # Fetch 'upper' color
|
|
|
|
return mix(color1, color2, weight2)
|
|
|
|
|
|
def expand_gradient(gradient, length):
|
|
"""Convert gradient palette into standard equal-interval palette.
|
|
|
|
:param sequence gradient: List or tuple of of 2-element lists/tuples
|
|
containing position (0.0 to 1.0) and color (packed int, CRGB or CHSV).
|
|
It's OK if the list/tuple elements are either lists OR tuples, but
|
|
don't mix and match lists and tuples -- use all one or the other.
|
|
|
|
:returns: CRGB list, can be used with palette_lookup() function.
|
|
"""
|
|
|
|
gradient = sorted(gradient) # Sort list by position values
|
|
least = gradient[0][0] # Lowest position value (ostensibly 0.0)
|
|
most = gradient[-1][0] # Highest position value (ostensibly 1.0)
|
|
newlist = []
|
|
|
|
for i in range(length):
|
|
pos = i / float(length - 1) # 0.0 to 1.0 in 'length' steps
|
|
# Determine indices in list of item 'below' and 'above' pos
|
|
if pos <= least:
|
|
# Off bottom of list - use lowest index
|
|
below, above = 0, 0
|
|
elif pos >= most:
|
|
# Off top of list - use highest index
|
|
below, above = -1, -1
|
|
else:
|
|
# Seek position between two items in list
|
|
below, above = 0, -1
|
|
for n, x in enumerate(gradient):
|
|
if pos >= x[0]:
|
|
below = n
|
|
for n, x in enumerate(gradient[-1:0:-1]):
|
|
if pos <= x[0]:
|
|
above = -1 - n
|
|
|
|
# Range between below, above
|
|
r = gradient[above][0] - gradient[below][0]
|
|
if r <= 0:
|
|
newlist.append(gradient[below][1]) # Use 'below' color only
|
|
else:
|
|
weight2 = (pos - gradient[below][0]) / r # Weight of 'above' color
|
|
color1 = gradient[below][1]
|
|
color2 = gradient[above][1]
|
|
# Interpolate and add to list
|
|
newlist.append(mix(color1, color2, weight2))
|
|
|
|
return newlist
|