Skip to content

Commit

Permalink
Merge 161278d into 879d6af
Browse files Browse the repository at this point in the history
  • Loading branch information
rm-hull committed Oct 30, 2020
2 parents 879d6af + 161278d commit b7b0565
Show file tree
Hide file tree
Showing 7 changed files with 158 additions and 129 deletions.
3 changes: 2 additions & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ ChangeLog
+------------+---------------------------------------------------------------------+------------+
| Version | Description | Date |
+============+=====================================================================+============+
| *TBC* | * Allow a wider range of SPI bus speeds | |
| **2.0.0** | * Improved framebuffer performance (breaking change) | TBC |
| | * Allow a wider range of SPI bus speeds | |
+------------+---------------------------------------------------------------------+------------+
| **1.17.3** | * Drop support for Python 3.5, only 3.6 or newer is supported now | 2020/10/24 |
| | * Add missing cmdline interfaces: "noop" & "gpio_cs_spi" | |
Expand Down
2 changes: 1 addition & 1 deletion luma/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
# Copyright (c) 2017-2020 Richard Hull and contributors
# See LICENSE.rst for details.

__version__ = '1.17.3'
__version__ = '2.0.0'
16 changes: 11 additions & 5 deletions luma/core/cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import argparse
import importlib
from collections import OrderedDict

from deprecated import deprecated


Expand Down Expand Up @@ -232,14 +233,17 @@ def create_device(args, display_types=None):
import luma.oled.device
Device = getattr(luma.oled.device, args.display)
interface = getattr(make_interface(args), args.interface)
device = Device(serial_interface=interface(), **vars(args))
framebuffer = getattr(luma.core.framebuffer, args.framebuffer)(num_segments=args.num_segments, debug=args.debug)
params = dict(vars(args), framebuffer=framebuffer)
device = Device(serial_interface=interface(), **params)

elif args.display in display_types.get('lcd', []):
import luma.lcd.device
Device = getattr(luma.lcd.device, args.display)
interface = getattr(make_interface(args), args.interface)()
backlight_params = dict(gpio=interface._gpio, gpio_LIGHT=args.gpio_backlight, active_low=args.backlight_active == "low")
params = dict(vars(args), **backlight_params)
framebuffer = getattr(luma.core.framebuffer, args.framebuffer)(num_segments=args.num_segments, debug=args.debug)
params = dict(vars(args), framebuffer=framebuffer, **backlight_params)
device = Device(serial_interface=interface, **params)
try:
import luma.lcd.aux
Expand Down Expand Up @@ -317,13 +321,15 @@ def create_parser(description):
misc_group = parser.add_argument_group('Misc')
misc_group.add_argument('--block-orientation', type=int, default=0, help=f'Fix 90° phase error (MAX7219 LED matrix only). Allowed values are: {block_orientation_choices_repr}', choices=block_orientation_choices, metavar='ORIENTATION')
misc_group.add_argument('--mode', type=str, default='RGB', help=f'Colour mode (SSD1322, SSD1325 and emulator only). Allowed values are: {color_choices_repr}', choices=color_choices, metavar='MODE')
misc_group.add_argument('--framebuffer', type=str, default=framebuffer_choices[0], help=f'Framebuffer implementation (SSD1331, SSD1322, ST7735 displays only). Allowed values are: {framebuffer_choices_repr}', choices=framebuffer_choices, metavar='FRAMEBUFFER')
misc_group.add_argument('--framebuffer', type=str, default=framebuffer_choices[0], help=f'Framebuffer implementation (SSD1331, SSD1322, ST7735, ILI9341 displays only). Allowed values are: {framebuffer_choices_repr}', choices=framebuffer_choices, metavar='FRAMEBUFFER')
misc_group.add_argument('--num-segments', type=int, default=4, help='Sets the number of segments to when using the diff-to-previous framebuffer implementation.')
misc_group.add_argument('--bgr', dest='bgr', action='store_true', help='Set if LCD pixels laid out in BGR (ST7735 displays only).')
misc_group.add_argument('--inverse', dest='inverse', action='store_true', help='Set if LCD has swapped black and white (ST7735 displays only).')
misc_group.set_defaults(bgr=False)
misc_group.add_argument('--h-offset', type=int, default=0, help='Horizontal offset (in pixels) of screen to display memory (ST7735 displays only)')
misc_group.add_argument('--v-offset', type=int, default=0, help='Vertical offset (in pixels) of screen to display memory (ST7735 displays only)')
misc_group.add_argument('--h-offset', type=int, default=0, help='Horizontal offset (in pixels) of screen to display memory (ST7735 displays only).')
misc_group.add_argument('--v-offset', type=int, default=0, help='Vertical offset (in pixels) of screen to display memory (ST7735 displays only).')
misc_group.add_argument('--backlight-active', type=str, default='low', help='Set to \"low\" if LCD backlight is active low, else \"high\" otherwise (PCD8544, ST7735 displays only). Allowed values are: low, high', choices=["low", "high"], metavar='VALUE')
misc_group.add_argument('--debug', dest='debug', action='store_true', help='Set to enable debugging.')

if len(display_types['emulator']) > 0:
transformer_choices = get_transformer_choices()
Expand Down
149 changes: 73 additions & 76 deletions luma/core/framebuffer.py
Original file line number Diff line number Diff line change
@@ -1,81 +1,100 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2014-18 Richard Hull and contributors
# Copyright (c) 2014-2020 Richard Hull and contributors
# See LICENSE.rst for details.

"""
Different implementation strategies for framebuffering
"""

from PIL import Image, ImageChops
from math import sqrt
from PIL import ImageChops, ImageDraw


class diff_to_previous(object):
"""
Compare the current frame to the previous frame and tries to calculate the
differences: this will either be ``None`` for a perfect match or some
bounding box describing the areas that are different, up to the size of the
entire image.
differences: this will either yield nothing for a perfect match or else
the iterator will yield one or more tuples comprising of the image part that
changed along with the bounding box that describes the areas that are different,
up to the size of the entire image.
The image data for the difference is then be passed to a device for
rendering just those small changes. This can be very quick for small screen
updates, but suffers from variable render times, depending on the changes
applied. The :py:class:`luma.core.sprite_system.framerate_regulator` may be
used to counteract this behavior however.
:param device: The target device, used to determine the initial 'previous'
image.
:type device: luma.core.device.device
:param num_segments: The number of segments to partition the image into. This
generally must be a square number (1, 4, 9, 16, ...) and must be able to
segment the image entirely in both width and height. i.e setting to 9 will
subdivide the image into a 3x3 grid when comparing to the previous image.
:type num_segments: int
:param debug: When set, draws a red box around each changed image segment to
show the area that changed. This is obviously destructive for displaying
the actual image, but intends to show where the tracked changes are.
:type debug: boolean
"""
def __init__(self, device):
self.image = Image.new(device.mode, device.size, "white")
self.bounding_box = None

def redraw_required(self, image):
"""
Calculates the difference from the previous image, return a boolean
indicating whether a redraw is required. A side effect is that
``bounding_box`` and ``image`` attributes are updated accordingly, as is
priming :py:func:`getdata`.
def __init__(self, num_segments=4, debug=False):
self.__debug = debug
self.__n = int(sqrt(num_segments))
assert num_segments >= 1 and num_segments == self.__n ** 2
self.prev_image = None

:param image: The image to render.
:type image: PIL.Image.Image
:returns: ``True`` or ``False``
:rtype: bool
def redraw(self, image):
"""
self.bounding_box = ImageChops.difference(self.image, image).getbbox()
if self.bounding_box is not None:
self.image = image.copy()
return True
else:
return False
Calculates the difference from the previous image, returning a sequence of
image sections and bounding boxes that changed since the previous image.
def inflate_bbox(self):
"""
Realign the left and right edges of the bounding box such that they are
inflated to align modulo 4.
.. note::
the first redraw will always render the full frame.
This method is optional, and used mainly to accommodate devices with
COM/SEG GDDRAM structures that store pixels in 4-bit nibbles.
:param image: The image to render.
:type image: PIL.Image.Image
:returns: Yields a sequence of images and the bounding box for each segment difference
:rtype: Generator[Tuple[PIL.Image.Image, Tuple[int, int, int, int]]]
"""
left, top, right, bottom = self.bounding_box
self.bounding_box = (
left & 0xFFFC,
top,
right if right % 4 == 0 else (right & 0xFFFC) + 0x04,
bottom)
image_width, image_height = image.size
segment_width = int(image_width / self.__n)
segment_height = int(image_height / self.__n)
assert segment_width * self.__n == image_width, "Total segment width does not cover full image width"
assert segment_height * self.__n == image_height, "Total segment height does not cover full image height"

return self.bounding_box
changes = 0

def getdata(self):
"""
A sequence of pixel data relating to the changes that occurred
since the last time :py:func:`redraw_required` was last called.
# Force a full redraw on the first frame
if self.prev_image is None:
changes += 1
yield image, (0, 0) + image.size

:returns: A sequence of pixels or ``None``.
:rtype: iterable
"""
if self.bounding_box:
return self.image.crop(self.bounding_box).getdata()
else:
for y in range(0, image_height, segment_height):
for x in range(0, image_width, segment_width):
bounding_box = (x, y, x + segment_width, y + segment_height)
prev_segment = self.prev_image.crop(bounding_box)
curr_segment = image.crop(bounding_box)
segment_bounding_box = ImageChops.difference(prev_segment, curr_segment).getbbox()
if segment_bounding_box is not None:
changes += 1
segment_bounding_box_from_origin = (
x + segment_bounding_box[0],
y + segment_bounding_box[1],
x + segment_bounding_box[2],
y + segment_bounding_box[3]
)

image_delta = curr_segment.crop(segment_bounding_box)

if self.__debug:
w, h = image_delta.size
draw = ImageDraw.Draw(image_delta)
draw.rectangle((0, 0, w - 1, h - 1), outline="red")
del draw

yield image_delta, segment_bounding_box_from_origin

if changes > 0:
self.prev_image = image.copy()


class full_frame(object):
Expand All @@ -85,37 +104,15 @@ class full_frame(object):
pixels to update on every render, but it has a more consistent render time.
Not all display drivers may be able to use the differencing framebuffer, so
this is provided as a drop-in replacement.
:param device: The target device, used to determine the bounding box.
:type device: luma.core.device.device
"""
def __init__(self, device):
self.bounding_box = (0, 0, device.width, device.height)

def redraw_required(self, image):
def redraw(self, image):
"""
Caches the image ready for getting the sequence of pixel data with
:py:func:`getdata`. This method always returns affirmatively.
Yields the full image for every redraw.
:param image: The image to render.
:type image: PIL.Image.Image
:returns: ``True`` always.
"""
self.image = image
return True

def inflate_bbox(self):
"""
Just return the original bounding box without any inflation.
"""
return self.bounding_box

def getdata(self):
"""
A sequence of pixels representing the full image supplied when the
:py:func:`redraw_required` method was last called.
:returns: A sequence of pixels.
:rtype: iterable
:returns: Yields a single tuple of an image and the bounding box for that image
:rtype: Generator[Tuple[PIL.Image.Image, Tuple[int, int, int, int]]]
"""
return self.image.getdata()
yield image, (0, 0) + image.size
3 changes: 3 additions & 0 deletions tests/test_cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ class test_spi_opts(object):
gpio_reset_release_time = 0

interface = 'spi'
framebuffer = 'diff_to_previous'
num_segments = 25
debug = False


def test_get_interface_types():
Expand Down
112 changes: 67 additions & 45 deletions tests/test_framebuffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,60 +4,82 @@
# See LICENSE.rst for details.

from PIL import Image, ImageDraw
from luma.core.device import dummy
from luma.core.framebuffer import full_frame, diff_to_previous


im1 = Image.new("1", (4, 4))
im1 = Image.new("RGB", (40, 40))
draw = ImageDraw.Draw(im1)
draw.line((0, 0) + (3, 0), fill="white")
draw.line((0, 0) + (39, 39), fill="white")

im2 = Image.new("1", (4, 4))
im2 = Image.new("RGB", (40, 40))
draw = ImageDraw.Draw(im2)
draw.line((0, 0) + (3, 0), fill="white")
draw.line((0, 0) + (0, 3), fill="white")

device = dummy(width=4, height=4, mode="1")
draw.line((0, 0) + (39, 39), fill="white")
draw.line((0, 39) + (39, 0), fill="white")


def test_full_frame():
framebuffer = full_frame(device)
assert framebuffer.redraw_required(im1)
pix1 = list(framebuffer.getdata())
assert len(pix1) == 16
assert pix1 == [0xFF] * 4 + [0x00] * 12
assert framebuffer.bounding_box == (0, 0, 4, 4)
assert framebuffer.inflate_bbox() == (0, 0, 4, 4)

assert framebuffer.redraw_required(im2)
pix2 = list(framebuffer.getdata())
assert len(pix2) == 16
assert pix2 == [0xFF] * 4 + [0xFF, 0x00, 0x00, 0x00] * 3
assert framebuffer.bounding_box == (0, 0, 4, 4)

assert framebuffer.redraw_required(im2)
pix3 = list(framebuffer.getdata())
assert len(pix3) == 16
assert pix3 == [0xFF] * 4 + [0xFF, 0x00, 0x00, 0x00] * 3
assert framebuffer.bounding_box == (0, 0, 4, 4)
framebuffer = full_frame()
redraws = list(framebuffer.redraw(im1))
assert len(redraws) == 1
assert redraws[0][0] == im1
assert redraws[0][1] == im1.getbbox()


def test_diff_to_previous():
framebuffer = diff_to_previous(device)
assert framebuffer.redraw_required(im1)
pix1 = list(framebuffer.getdata())
assert len(pix1) == 12
assert pix1 == [0x00] * 12
assert framebuffer.bounding_box == (0, 1, 4, 4)
assert framebuffer.inflate_bbox() == (0, 1, 4, 4)

assert framebuffer.redraw_required(im2)
pix2 = list(framebuffer.getdata())
assert len(pix2) == 3
assert pix2 == [0xFF] * 3
assert framebuffer.bounding_box == (0, 1, 1, 4)
assert framebuffer.inflate_bbox() == (0, 1, 4, 4)

assert not framebuffer.redraw_required(im2)
assert framebuffer.getdata() is None
assert framebuffer.bounding_box is None
framebuffer = diff_to_previous(num_segments=4)
redraws = list(framebuffer.redraw(im1))

# First redraw should be the full image
assert len(redraws) == 1
assert redraws[0][0] == im1
assert redraws[0][1] == im1.getbbox()
assert redraws[0][1] is not None

# Redraw of same image should return empty changeset
redraws = list(framebuffer.redraw(im1))
assert len(redraws) == 0

# Redraw of new image should return two changesets
redraws = list(framebuffer.redraw(im2))
assert len(redraws) == 2

assert redraws[0][0] == im2.crop((0, 20, 20, 40))
assert redraws[0][1] == (20, 0, 40, 20)

assert redraws[1][0] == im2.crop((20, 0, 40, 20))
assert redraws[1][1] == (0, 20, 20, 40)

# Redraw of original image should return two changesets
redraws = list(framebuffer.redraw(im1))
assert len(redraws) == 2

assert redraws[0][0] == im1.crop((20, 0, 40, 20))
assert redraws[0][1] == (20, 0, 40, 20)

assert redraws[1][0] == im1.crop((0, 20, 20, 40))
assert redraws[1][1] == (0, 20, 20, 40)


def test_diff_to_previous_debug():
framebuffer = diff_to_previous(num_segments=4, debug=True)
redraws = list(framebuffer.redraw(im1))

# First redraw should be the full image unchanged
assert len(redraws) == 1
assert redraws[0][0] == im1

# Redraw of new image should return two changesets with red borders around them
redraws = list(framebuffer.redraw(im2))
assert len(redraws) == 2

first_changeset_image = im2.copy().crop((20, 0, 40, 20))
draw = ImageDraw.Draw(first_changeset_image)
draw.rectangle((0, 0, 19, 19), outline="red")
del draw
assert redraws[0][0] == first_changeset_image

second_changeset_image = im2.copy().crop((0, 20, 20, 40))
draw = ImageDraw.Draw(second_changeset_image)
draw.rectangle((0, 0, 19, 19), outline="red")
del draw
assert redraws[1][0] == second_changeset_image

0 comments on commit b7b0565

Please sign in to comment.