/ adafruit_fancyled / adafruit_fancyled.py
adafruit_fancyled.py
  1  # The MIT License (MIT)
  2  #
  3  # Copyright (c) 2017 PaintYourDragon for Adafruit Industries
  4  #
  5  # Permission is hereby granted, free of charge, to any person obtaining a copy
  6  # of this software and associated documentation files (the "Software"), to deal
  7  # in the Software without restriction, including without limitation the rights
  8  # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  9  # copies of the Software, and to permit persons to whom the Software is
 10  # furnished to do so, subject to the following conditions:
 11  #
 12  # The above copyright notice and this permission notice shall be included in
 13  # all copies or substantial portions of the Software.
 14  #
 15  # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 16  # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 17  # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 18  # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 19  # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 20  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 21  # THE SOFTWARE.
 22  """
 23  `adafruit_fancyled.adafruit_fancyled`
 24  ====================================================
 25  
 26  FancyLED is a CircuitPython library to assist in creating buttery smooth LED animation.
 27  It's loosely inspired by the FastLED library for Arduino, and in fact we have a "helper"
 28  library using similar function names to assist with porting of existing Arduino FastLED
 29  projects to CircuitPython.
 30  
 31  * Author(s): PaintYourDragon
 32  """
 33  
 34  # imports
 35  
 36  __version__ = "0.0.0-auto.0"
 37  __repo__ = "https://github.com/Adafruit/Adafruit_CircuitPython_FancyLED.git"
 38  
 39  from math import floor
 40  
 41  
 42  # FancyLED provides color- and palette-related utilities for LED projects,
 43  # offering a buttery smooth look instead of the usual 8-bit-like "blip blip"
 44  # effects often seen with LEDs.  It's loosely inspired by, but NOT a drop-in
 45  # replacement for, the FastLED library for Arduino.
 46  
 47  
 48  class CRGB:
 49      """Color stored in Red, Green, Blue color space.
 50  
 51         One of two ways: separate red, gren, blue values (either as integers
 52         (0 to 255 range) or floats (0.0 to 1.0 range), either type is
 53         'clamped' to valid range and stored internally in the normalized
 54         (float) format), OR can accept a CHSV color as input, which will be
 55         converted and stored in RGB format.
 56  
 57         Following statements are equivalent - all return red:
 58  
 59         .. code-block:: python
 60  
 61               c = CRGB(255, 0, 0)
 62               c = CRGB(1.0, 0.0, 0.0)
 63               c = CRGB(CHSV(0.0, 1.0, 1.0))
 64      """
 65  
 66      def __init__(self, red, green=0.0, blue=0.0):
 67          # pylint: disable=too-many-branches
 68          if isinstance(red, CHSV):
 69              # If first/only argument is a CHSV type, perform HSV to RGB
 70              # conversion.
 71              hsv = red  # 'red' is CHSV, this is just more readable
 72              hue = hsv.hue * 6.0  # Hue circle = 0.0 to 6.0
 73              sxt = floor(hue)  # Sextant index is next-lower integer of hue
 74              frac = hue - sxt  # Fraction-within-sextant is 0.0 to <1.0
 75              sxt = int(sxt) % 6  # mod6 the sextant so it's always 0 to 5
 76  
 77              if sxt == 0:  # Red to <yellow
 78                  r, g, b = 1.0, frac, 0.0
 79              elif sxt == 1:  # Yellow to <green
 80                  r, g, b = 1.0 - frac, 1.0, 0.0
 81              elif sxt == 2:  # Green to <cyan
 82                  r, g, b = 0.0, 1.0, frac
 83              elif sxt == 3:  # Cyan to <blue
 84                  r, g, b = 0.0, 1.0 - frac, 1.0
 85              elif sxt == 4:  # Blue to <magenta
 86                  r, g, b = frac, 0.0, 1.0
 87              else:  # Magenta to <red
 88                  r, g, b = 1.0, 0.0, 1.0 - frac
 89  
 90              invsat = 1.0 - hsv.saturation  # Inverse-of-saturation
 91  
 92              self.red = ((r * hsv.saturation) + invsat) * hsv.value
 93              self.green = ((g * hsv.saturation) + invsat) * hsv.value
 94              self.blue = ((b * hsv.saturation) + invsat) * hsv.value
 95          else:
 96              # Red, green, blue arguments (normalized floats OR integers)
 97              # TODO(tannewt): Factor this out into a helper function
 98              if isinstance(red, float):
 99                  self.red = clamp(red, 0.0, 1.0)
100              else:
101                  self.red = normalize(red)
102              if isinstance(green, float):
103                  self.green = clamp(green, 0.0, 1.0)
104              else:
105                  self.green = normalize(green)
106              if isinstance(blue, float):
107                  self.blue = clamp(blue, 0.0, 1.0)
108              else:
109                  self.blue = normalize(blue)
110  
111      def __repr__(self):  # pylint: disable=invalid-repr-returned
112          return (self.red, self.green, self.blue)
113  
114      def __str__(self):
115          return "(%s, %s, %s)" % (self.red, self.green, self.blue)
116  
117      def __len__(self):
118          """Retrieve total number of color-parts available."""
119          return 3
120  
121      def __getitem__(self, key):
122          """Retrieve red, green or blue value as iterable."""
123          if key == 0:
124              return self.red
125          if key == 1:
126              return self.green
127          if key == 2:
128              return self.blue
129          raise IndexError
130  
131      def pack(self):
132          """'Pack' a `CRGB` color into a 24-bit RGB integer.
133  
134             :returns: 24-bit integer a la ``0x00RRGGBB``.
135          """
136  
137          return (
138              (denormalize(self.red) << 16)
139              | (denormalize(self.green) << 8)
140              | (denormalize(self.blue))
141          )
142  
143  
144  class CHSV:
145      """Color stored in Hue, Saturation, Value color space.
146  
147         Accepts hue as float (any range) or integer (0-256 -> 0.0-1.0) with
148         no clamping performed (hue can 'wrap around'), saturation and value
149         as float (0.0 to 1.0) or integer (0 to 255), both are clamped and
150         stored internally in the normalized (float) format.  Latter two are
151         optional, can pass juse hue and saturation/value will default to 1.0.
152  
153         Unlike `CRGB` (which can take a `CHSV` as input), there's currently
154         no equivalent RGB-to-HSV conversion, mostly because it's a bit like
155         trying to reverse a hash...there may be multiple HSV solutions for a
156         given RGB input.
157  
158         This might be OK as long as conversion precedence is documented,
159         but otherwise (and maybe still) could cause confusion as certain
160         HSV->RGB->HSV translations won't have the same input and output.
161          """
162  
163      def __init__(self, h, s=1.0, v=1.0):
164          if isinstance(h, float):
165              self.hue = h  # Don't clamp! Hue can wrap around forever.
166          else:
167              self.hue = float(h) / 256.0
168          if isinstance(s, float):
169              self.saturation = clamp(s, 0.0, 1.0)
170          else:
171              self.saturation = normalize(s)
172          if isinstance(v, float):
173              self.value = clamp(v, 0.0, 1.0)
174          else:
175              self.value = normalize(v)
176  
177      def __repr__(self):  # pylint: disable=invalid-repr-returned
178          return (self.hue, self.saturation, self.value)
179  
180      def __str__(self):
181          return "(%s, %s, %s)" % (self.hue, self.saturation, self.value)
182  
183      def __len__(self):
184          """Retrieve total number of 'color-parts' available."""
185          return 3
186  
187      def __getitem__(self, key):
188          """Retrieve hue, saturation or value as iterable."""
189          if key == 0:
190              return self.hue
191          if key == 1:
192              return self.saturation
193          if key == 2:
194              return self.value
195          raise IndexError
196  
197      def pack(self):
198          """'Pack' a `CHSV` color into a 24-bit RGB integer.
199  
200             :returns: 24-bit integer a la ``0x00RRGGBB``.
201          """
202  
203          # Convert CHSV to CRGB, return packed result
204          return CRGB(self).pack()
205  
206  
207  def clamp(val, lower, upper):
208      """Constrain value within a numeric range (inclusive).
209      """
210      return max(lower, min(val, upper))
211  
212  
213  def normalize(val, inplace=False):
214      """Convert 8-bit (0 to 255) value to normalized (0.0 to 1.0) value.
215  
216         Accepts integer, 0 to 255 range (input is clamped) or a list or tuple
217         of integers.  In list case, 'inplace' can be used to control whether
218         the original list is modified (True) or a new list is generated and
219         returned (False).
220  
221         Returns float, 0.0 to 1.0 range, or list of floats (or None if inplace).
222      """
223  
224      if isinstance(val, int):
225          # Divide by 255 (not 256) so maximum level is 1.0.
226          return clamp(val, 0, 255) / 255.0
227  
228      # If not int, is assumed list or tuple.
229      if inplace:
230          # Modify list in-place (OK for lists, NOT tuples, no check made)
231          for i, n in enumerate(val):
232              val[i] = normalize(n)
233          return None
234  
235      # Generate new list
236      return [normalize(n) for n in val]
237  
238  
239  def denormalize(val, inplace=False):
240      """Convert normalized (0.0 to 1.0) value to 8-bit (0 to 255) value
241  
242         Accepts float, 0.0 to 1.0 range or a list or tuple of floats.  In
243         list case, 'inplace' can be used to control whether the original list
244         is modified (True) or a new list is generated and returned (False).
245  
246         Returns integer, 0 to 255 range, or list of integers (or None if
247         inplace).
248      """
249  
250      # 'Denormalizing' math varies slightly from normalize().  This is on
251      # purpose.  Multiply by 256 (NOT 255) and clip.  This ensures that all
252      # fractional values fall into the correct 'buckets' -- e.g. 0.999
253      # should return 255, not 254 -- and that the buckets are all equal-
254      # sized (usu. method of adding 0.5 before int() would miss this).
255      if isinstance(val, float):
256          return clamp(int(val * 256.0), 0, 255)
257  
258      # If not int, is assumed list or tuple.
259      if inplace:
260          # Modify the list in-place (OK for lists, NOT tuples, no check made)
261          for i, n in enumerate(val):
262              val[i] = denormalize(n)
263          return None
264  
265      # Generate new list
266      return [denormalize(n) for n in val]
267  
268  
269  def unpack(val):
270      """'Unpack' a 24-bit color into a `CRGB` instance.
271  
272         :param int val:  24-bit integer a la ``0x00RRGGBB``.
273         :returns: CRGB color.
274         :rtype: CRGB
275      """
276  
277      # See notes in normalize() for math explanation.  Large constants here
278      # avoid the usual shift-right step, e.g. 16711680.0 is 255 * 256 * 256,
279      # so we can just mask out the red and divide by this for 0.0 to 1.0.
280      return CRGB(
281          (val & 0xFF0000) / 16711680.0,  # Red
282          (val & 0x00FF00) / 65280.0,  # Green
283          (val & 0x0000FF) / 255.0,
284      )  # Blue
285  
286  
287  def mix(color1, color2, weight2=0.5):
288      """Blend between two colors using given ratio. Accepts two colors (each
289         may be `CRGB`, `CHSV` or packed integer), and weighting (0.0 to 1.0)
290         of second color.
291  
292         :returns: `CRGB` color in most cases, `CHSV` if both inputs are `CHSV`.
293      """
294  
295      clamp(weight2, 0.0, 1.0)
296      weight1 = 1.0 - weight2
297  
298      if isinstance(color1, CHSV):
299          if isinstance(color2, CHSV):
300              # Both colors are CHSV -- interpolate in HSV color space
301              # because of the way hue can cross the unit boundary...
302              # e.g. if the hues are 0.25 and 0.75, the center point is
303              # 0.5 (cyan)...but if you want hues to wrap the other way
304              # (with red at the center), you can have hues of 1.25 and 0.75.
305              hue = color1.hue + ((color2.hue - color1.hue) * weight2)
306              sat = color1.saturation * weight1 + color2.saturation * weight2
307              val = color1.value * weight1 + color2.value * weight2
308              return CHSV(hue, sat, val)
309          # Else color1 is HSV, color2 is RGB.  Convert color1 to RGB
310          # before doing interpolation in RGB space.
311          color1 = CRGB(color1)
312          # If color2 is a packed integer, convert to CRGB instance.
313          if isinstance(color2, int):
314              color2 = unpack(color2)
315      else:
316          if isinstance(color2, CHSV):
317              # color1 is RGB, color2 is HSV.  Convert color2 to RGB
318              # before interpolating in RGB space.
319              color2 = CRGB(color2)
320          elif isinstance(color2, int):
321              # If color2 is a packed integer, convert to CRGB instance.
322              color2 = unpack(color2)
323          # If color1 is a packed integer, convert to CRGB instance.
324          if isinstance(color1, int):
325              color1 = unpack(color1)
326  
327      # Interpolate and return as CRGB type
328      return CRGB(
329          (color1.red * weight1 + color2.red * weight2),
330          (color1.green * weight1 + color2.green * weight2),
331          (color1.blue * weight1 + color2.blue * weight2),
332      )
333  
334  
335  GFACTOR = 2.7  # Default gamma-correction factor for function below
336  
337  
338  def gamma_adjust(val, gamma_value=None, brightness=1.0, inplace=False):
339      """Provides gamma adjustment for single values, `CRGB` and `CHSV` types
340         and lists of any of these.
341  
342         Works in one of three ways:
343           1. Accepts a single normalized level (0.0 to 1.0) and optional
344              gamma-adjustment factor (float usu. > 1.0, default if
345              unspecified is GFACTOR) and brightness (float 0.0 to 1.0,
346              default is 1.0). Returns a single normalized gamma-corrected
347              brightness level (0.0 to 1.0).
348           2. Accepts a single `CRGB` or `CHSV` type, optional single gamma
349              factor OR a (R,G,B) gamma tuple (3 values usu. > 1.0), optional
350              single brightness factor OR a (R,G,B) brightness tuple.  The
351              input tuples are RGB even when a `CHSV` color is passed. Returns
352              a normalized gamma-corrected `CRGB` type (NOT `CHSV`!).
353           3. Accept a list or tuple of normalized levels, `CRGB` or `CHSV`
354              types (and optional gamma and brightness levels or tuples
355              applied to all). Returns a list of gamma-corrected values or
356              `CRGB` types (NOT `CHSV`!).
357  
358         In cases 2 and 3, if the input is a list (NOT a tuple!), the 'inplace'
359         flag determines whether a new tuple/list is calculated and returned,
360         or the existing value is modified in-place.  By default this is
361         'False'.  If you try to inplace-modify a tuple, an exception is raised.
362  
363         In cases 2 and 3, there is NO return value if 'inplace' is True --
364         the original values are modified.
365      """
366      # pylint: disable=too-many-branches
367  
368      if isinstance(val, float):
369          # Input value appears to be a single float
370          if gamma_value is None:
371              gamma_value = GFACTOR
372          return pow(val, gamma_value) * brightness
373  
374      if isinstance(val, (list, tuple)):
375          # List or tuple of values
376          if isinstance(val[0], float):
377              # Input appears to be a list of floats
378              if gamma_value is None:
379                  gamma_value = GFACTOR
380              if inplace:
381                  for i, x in enumerate(val):
382                      val[i] = pow(val[i], gamma_value) * brightness
383                  return None
384              newlist = []
385              for x in val:
386                  newlist.append(pow(x, gamma_value) * brightness)
387              return newlist
388          # List of CRGB or CHSV...we'll get back to that in a moment...
389          # but first determine gamma-correction factors for R,G,B:
390          if gamma_value is None:
391              # No gamma specified, use default
392              gamma_red, gamma_green, gamma_blue = GFACTOR, GFACTOR, GFACTOR
393          elif isinstance(gamma_value, float):
394              # Single gamma value provided, apply to R,G,B
395              gamma_red, gamma_green, gamma_blue = (gamma_value, gamma_value, gamma_value)
396          else:
397              gamma_red, gamma_green, gamma_blue = (
398                  gamma_value[0],
399                  gamma_value[1],
400                  gamma_value[2],
401              )
402          if isinstance(brightness, float):
403              # Single brightness value provided, apply to R,G,B
404              brightness_red, brightness_green, brightness_blue = (
405                  brightness,
406                  brightness,
407                  brightness,
408              )
409          else:
410              brightness_red, brightness_green, brightness_blue = (
411                  brightness[0],
412                  brightness[1],
413                  brightness[2],
414              )
415          if inplace:
416              for i, x in enumerate(val):
417                  if isinstance(x, CHSV):
418                      x = CRGB(x)
419                  val[i] = CRGB(
420                      pow(x.red, gamma_red) * brightness_red,
421                      pow(x.green, gamma_green) * brightness_green,
422                      pow(x.blue, gamma_blue) * brightness_blue,
423                  )
424              return None
425          newlist = []
426          for x in val:
427              if isinstance(x, CHSV):
428                  x = CRGB(x)
429              newlist.append(
430                  CRGB(
431                      pow(x.red, gamma_red) * brightness_red,
432                      pow(x.green, gamma_green) * brightness_green,
433                      pow(x.blue, gamma_blue) * brightness_blue,
434                  )
435              )
436          return newlist
437  
438      # Single CRGB or CHSV value
439      if gamma_value is None:
440          # No gamma specified, use default
441          gamma_red, gamma_green, gamma_blue = GFACTOR, GFACTOR, GFACTOR
442      elif isinstance(gamma_value, float):
443          # Single gamma value provided, apply to R,G,B
444          gamma_red, gamma_green, gamma_blue = (gamma_value, gamma_value, gamma_value)
445      else:
446          gamma_red, gamma_green, gamma_blue = (
447              gamma_value[0],
448              gamma_value[1],
449              gamma_value[2],
450          )
451      if isinstance(brightness, float):
452          # Single brightness value provided, apply to R,G,B
453          brightness_red, brightness_green, brightness_blue = (
454              brightness,
455              brightness,
456              brightness,
457          )
458      else:
459          brightness_red, brightness_green, brightness_blue = (
460              brightness[0],
461              brightness[1],
462              brightness[2],
463          )
464  
465      if isinstance(val, CHSV):
466          val = CRGB(val)
467  
468      return CRGB(
469          pow(val.red, gamma_red) * brightness_red,
470          pow(val.green, gamma_green) * brightness_green,
471          pow(val.blue, gamma_blue) * brightness_blue,
472      )
473  
474  
475  def palette_lookup(palette, position):
476      """Fetch color from color palette, with interpolation.
477  
478      :param palette: color palette (list of CRGB, CHSV and/or packed integers)
479      :param float position: palette position (0.0 to 1.0, wraps around).
480  
481      :returns: `CRGB` or `CHSV` instance, no gamma correction applied.
482      """
483  
484      position %= 1.0  # Wrap palette position in 0.0 to <1.0 range
485  
486      weight2 = position * len(palette)  # Scale position to palette length
487      idx = int(floor(weight2))  # Index of 'lower' color (0 to len-1)
488      weight2 -= idx  # Weighting of 'upper' color
489  
490      color1 = palette[idx]  # Fetch 'lower' color
491      idx = (idx + 1) % len(palette)  # Get index of 'upper' color
492      color2 = palette[idx]  # Fetch 'upper' color
493  
494      return mix(color1, color2, weight2)
495  
496  
497  def expand_gradient(gradient, length):
498      """Convert gradient palette into standard equal-interval palette.
499  
500      :param sequence gradient: List or tuple of of 2-element lists/tuples
501        containing position (0.0 to 1.0) and color (packed int, CRGB or CHSV).
502        It's OK if the list/tuple elements are either lists OR tuples, but
503        don't mix and match lists and tuples -- use all one or the other.
504  
505      :returns: CRGB list, can be used with palette_lookup() function.
506      """
507  
508      gradient = sorted(gradient)  # Sort list by position values
509      least = gradient[0][0]  # Lowest position value (ostensibly 0.0)
510      most = gradient[-1][0]  # Highest position value (ostensibly 1.0)
511      newlist = []
512  
513      for i in range(length):
514          pos = i / float(length - 1)  # 0.0 to 1.0 in 'length' steps
515          # Determine indices in list of item 'below' and 'above' pos
516          if pos <= least:
517              # Off bottom of list - use lowest index
518              below, above = 0, 0
519          elif pos >= most:
520              # Off top of list - use highest index
521              below, above = -1, -1
522          else:
523              # Seek position between two items in list
524              below, above = 0, -1
525              for n, x in enumerate(gradient):
526                  if pos >= x[0]:
527                      below = n
528              for n, x in enumerate(gradient[-1:0:-1]):
529                  if pos <= x[0]:
530                      above = -1 - n
531  
532          # Range between below, above
533          r = gradient[above][0] - gradient[below][0]
534          if r <= 0:
535              newlist.append(gradient[below][1])  # Use 'below' color only
536          else:
537              weight2 = (pos - gradient[below][0]) / r  # Weight of 'above' color
538              color1 = gradient[below][1]
539              color2 = gradient[above][1]
540              # Interpolate and add to list
541              newlist.append(mix(color1, color2, weight2))
542  
543      return newlist