Source code for pisense.screen

# vim: set et sw=4 sts=4 fileencoding=utf-8:
#
# Alternative API for the Sense HAT
# Copyright (c) 2016-2018 Dave Jones <dave@waveform.org.uk>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
#     * Redistributions of source code must retain the above copyright
#       notice, this list of conditions and the following disclaimer.
#     * Redistributions in binary form must reproduce the above copyright
#       notice, this list of conditions and the following disclaimer in the
#       documentation and/or other materials provided with the distribution.
#     * Neither the name of the copyright holder nor the
#       names of its contributors may be used to endorse or promote products
#       derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

"""
Defines the :class:`SenseScreen` classes for controlling and manipulating the
RGB pixel array on the Sense HAT.
"""

from __future__ import (
    unicode_literals,
    absolute_import,
    print_function,
    division,
)

import io
import os
import glob
import mmap
import errno
import time
import struct
import fcntl

import numpy as np
from colorzero import Color

from .array import ScreenArray
from .easings import linear
from .anim import scroll_text, fade_to, slide_to, zoom_to
from .formats import (
    color_dtype,
    buf_to_image,
    image_to_rgb565,
    rgb565_to_image,
    rgb_to_rgb565,
    rgb565_to_rgb,
)

# Make Py2's str and range equivalent to Py3's
native_str = str  # pylint: disable=invalid-name
str = type('')  # pylint: disable=redefined-builtin,invalid-name

DEFAULT_GAMMA = [0, 0, 0, 0, 0, 0, 1, 1,
                 2, 2, 3, 3, 4, 5, 6, 7,
                 8, 9, 10, 11, 12, 14, 15, 17,
                 18, 20, 21, 23, 25, 27, 29, 31]

LOW_GAMMA = [0, 1, 1, 1, 1, 1, 1, 1,
             1, 1, 1, 1, 1, 2, 2, 2,
             3, 3, 3, 4, 4, 5, 5, 6,
             6, 7, 7, 8, 8, 9, 10, 10]


[docs]class SenseScreen(object): """ The :class:`SenseScreen` class represents the LED matrix on the Sense HAT. Users can either instantiate this class themselves, or can access an instance from :attr:`SenseHAT.screen`. The two primary means of accessing and manipulating the screen are: * The :attr:`array` attribute which returns a :class:`ScreenArray` (a customized :class:`numpy.ndarray`). If the array is manipulated, it will update the screen "live". * The :meth:`image` and :meth:`draw` methods. The former returns the current state of the display as an 8x8 PIL :class:`~PIL.Image.Image`, while the latter updates the screen to display the provided image. Attributes are provided to modify the :attr:`rotation` of the display, and the :attr:`gamma` table. The :attr:`hflip` and :attr:`vflip` attributes can be used to mirror the display horizontally and vertically. Finally, several methods are provided for playing animations: :meth:`slide_to`, :meth:`fade_to`, :meth:`zoom_to`, and :meth:`scroll_text` each of which accept either image or array representations of the screen. The *fps* parameter specifies the default frames per second for generation and playback of animations (the default if unspecified is 15fps). The *easing* parameter likewise specifies the default :ref:`easing function <easing>` for generation of animations. If the *emulate* parameter is ``True``, the instance will connect to the screen on the `desktop Sense HAT emulator`_ instead of the "real" Sense HAT screen. .. _desktop Sense HAT emulator: https://sense-emu.readthedocs.io/ """ # pylint: disable=too-many-instance-attributes __slots__ = ( '_fb_file', '_fb_mmap', '_fb_array', '_array', '_hflip', '_vflip', '_rotation', '_emulate', 'fps', 'easing', ) SENSE_HAT_FB_NAME = 'RPi-Sense FB' GET_GAMMA = 61696 SET_GAMMA = 61697 RESET_GAMMA = 61698 GAMMA_DEFAULT = 0 GAMMA_LOW = 1 GAMMA_USER = 2 def __init__(self, fps=15, easing=linear, emulate=False): self._emulate = emulate if emulate: from sense_emu.screen import init_screen self._fb_file = init_screen() else: self._fb_file = io.open(self._fb_device(), 'rb+', buffering=0) self._fb_mmap = mmap.mmap(self._fb_file.fileno(), 128) self._fb_array = np.frombuffer(self._fb_mmap, dtype=np.uint16).reshape((8, 8)) self._array = ScreenArray() # pylint: disable=protected-access self._array._screen = self self._hflip = False self._vflip = False self._rotation = 0 self.fps = fps self.easing = easing
[docs] def close(self): """ Call the :meth:`close` method to close the screen interface and free up any background resources. The method is idempotent (you can call it multiple times without error) and after it is called, any operations on the screen may return an error (but are not guaranteed to do so). """ if self._fb_array is not None: self._fb_array = None self._fb_mmap.close() self._fb_file.close() # pylint: disable=protected-access self._array._screen = None self._array = None
def __enter__(self): return self def __exit__(self, exc_type, exc_value, exc_tb): self.close() def _fb_device(self): for device in glob.glob('/sys/class/graphics/fb*'): try: with io.open(os.path.join(device, 'name'), 'r') as f: if f.read().strip() == self.SENSE_HAT_FB_NAME: return os.path.join('/dev', os.path.basename(device)) except IOError as exc: if exc.errno != errno.ENOENT: raise raise RuntimeError('unable to locate SenseHAT framebuffer device') @property def raw(self): """ Provides direct access to the Sense HAT's RGB565 framebuffer. This attribute returns a numpy :class:`~numpy.ndarray` containing 8x8 unsigned 16-bit integer elements, each of which represents a single pixel on the display in RGB565 format (5-bits for red, 6-bits for green, 5-bits for blue). Internally, the screen actually uses 5-bits for all colors (the LSB of green is dropped); see :attr:`gamma` for more information. The array that is returned is built upon the framebuffer's memory. In other words, manipulating the array directly mainpulates the framebuffer. As such, this property will *not* be affected by :attr:`hflip`, :attr:`vflip` or :attr:`rotation`. .. note:: Generally you should have no need to use this property. The :attr:`array` attribute and :meth:`image` method are far simpler to work with. """ return self._fb_array def _poke(self): # "Touch" the frame-buffer file; this is necessary to let the Sense HAT # emulator know that we've changed the contents of the framebuffer and # it should update from it. Unfortunately, futimes(3) is not # universally supported, and only available in Python 3.3+ so this gets # a bit convoluted... try: if os.utime in os.supports_fd: os.utime(self._fb_file.fileno()) else: raise NotImplementedError except (AttributeError, NotImplementedError) as e: # Fall back to using the filename os.utime(self._fb_file.name, None) @raw.setter def raw(self, value): self._fb_array[:] = value if self._emulate: self._poke() @property def gamma(self): """ Returns the gamma lookup table for the screen. This property returns a 32-element array of integer values each of which is in the range 0 to 31 (5-bits). This forms the "gamma table" of the Sense HAT's screen and is used to map intensities to their final values on the screen. Internally, the Sense HAT's framebuffer uses 5-bits (values 0 to 31) to represent each color. After a color's `Least significant bits`_ have been stripped to reduce it to 5-bits, the resulting value is then used as an index into this list. The value obtained from this lookup will be the final value used when lighting the corresponding LED. Two "standard" gamma tables are provided: :data:`DEFAULT_GAMMA` and :data:`LOW_GAMMA` which can be assigned directly to this property:: >>> import pisense >>> hat = pisense.SenseHAT() >>> hat.screen.gamma = pisense.LOW_GAMMA .. note:: This property is designed to be assigned to in its entirety. The list returned by it is not "live" (it is a copy of the actual gamma table) and changing individual elements in it will *not* change the gamma settings. .. _Least significant bits: https://en.wikipedia.org/wiki/Bit_numbering """ buf = bytearray(32) fcntl.ioctl(self._fb_file, SenseScreen.GET_GAMMA, buf) return list(buf) @gamma.setter def gamma(self, value): if value is None: fcntl.ioctl(self._fb_file, SenseScreen.RESET_GAMMA, 0) else: if len(value) != 32: raise ValueError('gamma array must contain 32 entries') if not all(isinstance(v, int) and 0 <= v < 32 for v in value): raise ValueError('gamma values must be in the range 0..31') buf = struct.pack(native_str('32B'), *value) fcntl.ioctl(self._fb_file, SenseScreen.SET_GAMMA, buf) @property def array(self): """ Returns the screen as a :class:`ScreenArray` (a customized :class:`numpy.ndarray`). The returned array is "live" and modifications to it will modify the state of the screen. See :class:`ScreenArray` for more information on the usage and facilities of this class. """ arr = self._array # pylint: disable=protected-access arr._screen = None try: rgb565_to_rgb(self.raw, arr) arr = self._undo_transforms(arr) finally: arr._screen = self return arr @array.setter def array(self, value): if isinstance(value, np.ndarray): if value.dtype == color_dtype: value = value.reshape((8, 8)) else: value = value.astype(np.float32).view(color_dtype).reshape((8, 8)) else: value = np.array(value, dtype=color_dtype).reshape((8, 8)) value = self._apply_transforms(value) rgb_to_rgb565(value, self.raw) if self._emulate: self._poke() @property def vflip(self): """ When set to ``True`` the display will be mirrored vertically. Defaults to ``False``. """ return self._vflip @vflip.setter def vflip(self, value): raw = self._undo_transforms(self.raw) self._vflip = bool(value) self.raw = self._apply_transforms(raw) @property def hflip(self): """ When set to ``True`` the display will be mirrored horizontally. Defaults to ``False``. """ return self._hflip @hflip.setter def hflip(self, value): raw = self._undo_transforms(self.raw) self._hflip = bool(value) self.raw = self._apply_transforms(raw) @property def rotation(self): """ Specifies the rotation (really, the orientation) of the screen as a multiple of 90 degrees. When rotation is 0 (the default), Y is 0 near the GPIO pins and increases towards the Raspberry Pi logo, while X is 0 near the notch at the edge of the board and increases towards the joystick: .. image:: images/rotation_0.* When rotation is 90, Y is 0 near the notch at the edge of the board and increases towards the joystick, while X is 0 near the Raspberry Pi logo, and increases towards the GPIO pins: .. image:: images/rotation_90.* The other two rotations are trivial to derive from this. .. note:: This property is updated by the unifying :attr:`SenseHAT.rotation` attribute. """ return self._rotation @rotation.setter def rotation(self, value): if value % 90: raise ValueError('rotation must be a multiple of 90') raw = self._undo_transforms(self.raw) self._rotation = value % 360 self.raw = self._apply_transforms(raw) def _apply_transforms(self, arr): if self._vflip: arr = np.flipud(arr) if self._hflip: arr = np.fliplr(arr) arr = np.rot90(arr, self._rotation // 90) return arr def _undo_transforms(self, arr): arr = np.rot90(arr, (360 - self._rotation) // 90) if self._hflip: arr = np.fliplr(arr) if self._vflip: arr = np.flipud(arr) return arr
[docs] def clear(self, fill=Color('black')): """ Set all pixels in the display to the same *fill* color, which defaults to black (off). *fill* can be a :class:`~colorzero.Color` instance, or anything that could be used to construct a :class:`~colorzero.Color` instance. """ if not isinstance(fill, Color): fill = Color(*fill) self.raw = fill.rgb565
[docs] def image(self): """ Return an 8x8 PIL :class:`~PIL.Image.Image` representing the current state of the display. The image returned is a copy of the display's state. Drawing on the image will *not* update the display. Instead, it is recommended that you perform whatever drawing you wish (e.g. with :mod:`~PIL.ImageDraw`), then call :meth:`draw` with the image to update the display. """ return rgb565_to_image(self._undo_transforms(self.raw))
[docs] def draw(self, image): """ Draw the provided image (or array) on the display. The *image* passed to this method can be anything accepted by :func:`buf_to_image`. The only restriction is that the result must be an 8x8 image. """ img = buf_to_image(image) if img.size != (8, 8): raise ValueError('image must be an 8x8 RGB PIL Image') arr = image_to_rgb565(img) arr = self._apply_transforms(arr) self.raw = arr
[docs] def play(self, frames): """ Play an animation on the display. The *frames* provided to this method must be in one of the formats accepted by the :attr:`draw` method; *frames* itself can be any iterable, including a generator. Frames will be played back at a rate governed by the :attr:`fps` attribute. """ delay = 1 / self.fps for frame in frames: if (isinstance(frame, np.ndarray) and frame.shape == (8, 8) and frame.dtype == np.uint16): # Fast-path self.raw = self._apply_transforms(frame) else: self.draw(frame) time.sleep(delay)
[docs] def scroll_text(self, text, font='default.pil', size=8, foreground=Color('white'), background=Color('black'), direction='left', duration=None, fps=None): """ Renders *text* in the specified *font* and *size*, and scrolls the result across the display. See the :func:`scroll_text` function for more information on the meaning of the parameters. This method simply calls that function with the provided parameters, and passes the result to :meth:`play`. """ # pylint: disable=too-many-arguments frames = scroll_text(text, font, size, foreground, background, direction, duration, self.fps if fps is None else fps) # Pre-calc all the frames in the raw RGR565 format; doesn't take a huge # amount of memory and ensures a smooth playback even on tiny # platforms like the A+ frames = [image_to_rgb565(frame) for frame in frames] self.play(frames)
[docs] def fade_to(self, image, duration=1, fps=None, easing=None): """ Smoothly fades the display from its current state to the provided *image* (which can be anything compatible with :meth:`draw`). See the :func:`fade_to` function for more information on the meaning of the parameters. This method simply calls that function with the current state of the display (via :meth:`image`) and the provided parameters, and passes the result to :meth:`play`. """ frames = fade_to(self.image(), image, duration, self.fps if fps is None else fps, self.easing if easing is None else easing) frames = [image_to_rgb565(frame) for frame in frames] self.play(frames)
[docs] def slide_to(self, image, direction='left', cover=False, duration=1, fps=None, easing=None): """ Slide *image* (which can be anything compatible with :meth:`draw`) over the display in the specified *direction*. See the :func:`slide_to` function for more information on the meaning of the parameters. This method simply calls that function with the current state of the display (via :meth:`image`) and the provided parameters, and passes the result to :meth:`play`. """ # pylint: disable=too-many-arguments frames = slide_to(self.image(), image, direction, cover, duration, self.fps if fps is None else fps, self.easing if easing is None else easing) frames = [image_to_rgb565(frame) for frame in frames] self.play(frames)
[docs] def zoom_to(self, image, center=(4, 4), direction='in', duration=1, fps=None, easing=None): """ Zoom the display in or out (specified by *direction*) to *image* (which can be anything compatible with :meth:`draw`). See the :func:`zoom_to` function for more information on the meaning of the parameters. This method simply calls that function with the current state of the display (via :meth:`image`) and the provided parameters, and passes the result to :meth:`play`. """ # pylint: disable=too-many-arguments frames = zoom_to(self.image(), image, center, direction, duration, self.fps if fps is None else fps, self.easing if easing is None else easing) frames = [image_to_rgb565(frame) for frame in frames] self.play(frames)
[docs] def wipe_to(self, image, direction='right', duration=1, fps=None, easing=None): """ Wipe *image* (which can be anything compatible with :meth:`draw`) over the display. See the :func:`wipe_to` function for more information on the meaning of the parameters. This method simply calls that function with the current state of the display (via :meth:`image`) and the provided parameters, and passes the result to :meth:`play`. """ # pylint: disable=too-many-arguments frames = wipe_to(self.image(), image, direction, duration, self.fps if fps is None else fps, self.easing if easing is None else easing) frames = [image_to_rgb565(frame) for frame in frames] self.play(frames)