Repository: rm-hull/luma.core

Issue:
Possible to use diff-algorithm with linux_framebuffer device?

#### Type of Raspberry Pi

Raspberry Pi 3 Model B (v1.2)


#### Linux Kernel version

`Linux raspberrypi 5.4.51-v7+ #1333 SMP Mon Aug 10 16:45:19 BST 2020 armv7l GNU/Linux`
(Buster release of Raspberry Pi OS)


#### Question

Thanks for adding the linux_framebuffer device into luma.core!  I'm just getting the chance to experiment with it.

Is it possible at all to make use of luma's **diff_to_previous** algorithm for updates to the fbdev framebuffer?  I ask just since the full update (I assume that's what is currently being used) is taking a little over 1/4 of a second.

With an update loop that looks as follows:
```
        while True:
            try:
                start_time = datetime.now()
                draw.rectangle([(0,0), (frame_size[0],frame_size[1])], 'black', 'black')
                draw.text(( 5, 5), "Hello, world",  fill='white', font=font_main)
                device.display(image)
                #update_display()   -- isolate update time to just fb update
                print(datetime.now(), "update_display() took ", datetime.now() - start_time)
```

some representative results are
```
2020-11-27 11:27:25.556874 update_display() took  0:00:00.292568
2020-11-27 11:27:26.745018 update_display() took  0:00:00.276966
2020-11-27 11:27:27.929180 update_display() took  0:00:00.273018
2020-11-27 11:27:29.115797 update_display() took  0:00:00.275477
2020-11-27 11:27:30.320051 update_display() took  0:00:00.293113
2020-11-27 11:27:31.510489 update_display() took  0:00:00.279289
2020-11-27 11:27:32.691867 update_display() took  0:00:00.270262
2020-11-27 11:27:33.900963 update_display() took  0:00:00.297949
2020-11-27 11:27:35.108634 update_display() took  0:00:00.296486
2020-11-27 11:27:36.315688 update_display() took  0:00:00.295907
2020-11-27 11:27:37.499120 update_display() took  0:00:00.272288
2020-11-27 11:27:38.698876 update_display() took  0:00:00.288624
2020-11-27 11:27:39.891200 update_display() took  0:00:00.281173
2020-11-27 11:27:41.077159 update_display() took  0:00:00.274793
```

The above is with an ILI9341-based display driven using tinyDRM's `mi0283qt` driver.  The dtoverlay file is currently specifying a SPI frequency of 32 MHz.  The SPI bus could certainly be set faster, but I thought I'd inquire about the fbdev update.

Thanks again!
  Matt


Discussion:
thijstriemstra: @mattblovell can you also take a look at this PR?

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 2187be3..f695910 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -4,6 +4,8 @@ ChangeLog
 +------------+---------------------------------------------------------------------+------------+
 | Version    | Description                                                         | Date       |
 +============+=====================================================================+============+
+| **2.1.0**  | * Linux framebuffer pseudo-device uses diff-to-previous algorithm   | TBC        |
++------------+---------------------------------------------------------------------+------------+
 | **2.0.2**  | * Change order of namepaces cmdline, so SSD1306 is default          | 2020/11/26 |
 +------------+---------------------------------------------------------------------+------------+
 | **2.0.1**  | * Fix to allow cmdline args to dynamically create a full_frame()    | 2020/11/06 |
diff --git a/luma/core/cmdline.py b/luma/core/cmdline.py
index e42c918..68e8634 100644
--- a/luma/core/cmdline.py
+++ b/luma/core/cmdline.py
@@ -233,7 +233,9 @@ def create_device(args, display_types=None):
     if args.display in display_types.get('core', []):
         import luma.core.device
         Device = getattr(luma.core.device, args.display)
-        device = Device(device=args.framebuffer_device, **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(device=args.framebuffer_device, **params)
 
     elif args.display in display_types.get('oled', []):
         import luma.oled.device
diff --git a/luma/core/device.py b/luma/core/device.py
index a1f979c..30bf514 100644
--- a/luma/core/device.py
+++ b/luma/core/device.py
@@ -5,9 +5,11 @@
 import os
 import atexit
 from time import sleep
+from itertools import islice
 
 from luma.core import mixin
 from luma.core.util import bytes_to_nibbles
+from luma.core.framebuffer import diff_to_previous
 import luma.core.const
 from luma.core.interface.serial import i2c, noop
 
@@ -192,11 +194,13 @@ class linux_framebuffer(device):
         is given, the device is determined from the `FRAMEBUFFER` environmental
         variable instead. See https://www.kernel.org/doc/html/latest/fb/framebuffer.html
         for more details.
+    :param framebuffer: Framebuffer rendering strategy, currently instances of
+        ``diff_to_previous`` (default, if not specified) or ``full_frame``.
 
     .. versionadded:: 2.0.0
     """
 
-    def __init__(self, device=None, **kwargs):
+    def __init__(self, device=None, framebuffer=None, **kwargs):
         super(linux_framebuffer, self).__init__(serial_interface=noop())
         self.id = self.__get_display_id(device)
         (width, height) = self.__config("virtual_size")
@@ -208,8 +212,12 @@ class linux_framebuffer(device):
         assert self.bits_per_pixel in image_converters, f"Unsupported bit-depth: {self.bits_per_pixel}"
         self.__image_converter = image_converters[self.bits_per_pixel]
 
+        self.framebuffer = framebuffer or diff_to_previous(num_segments=16)
         self.capabilities(width, height, rotate=0, mode="RGB")
 
+        path = f"/dev/fb{self.id}"
+        self.__file_handle = open(path, "wb")
+
     def __get_display_id(self, device):
         """
         Extract the display-id from the device which is usually referred in
@@ -241,7 +249,11 @@ class linux_framebuffer(device):
             yield r & 0xF8 | g >> 5
 
     def __toRGB(self, image):
-        return image.tobytes()
+        return iter(image.tobytes())
+
+    def cleanup(self):
+        super(linux_framebuffer, self).cleanup()
+        self.__file_handle.close()
 
     def display(self, image):
         """
@@ -255,8 +267,20 @@ class linux_framebuffer(device):
         assert image.size == self.size
 
         image = self.preprocess(image)
-        path = f"/dev/fb{self.id}"
-        data = bytes(self.__image_converter(image))
-
-        with open(path, "wb") as fp:
-            fp.write(data)
+        file_handle = self.__file_handle
+
+        bytes_per_pixel = self.bits_per_pixel // 8
+        image_bytes_per_row = self.width * bytes_per_pixel
+
+        for image, bounding_box in self.framebuffer.redraw(image):
+            left, top, right, bottom = bounding_box
+            segment_bytes_per_row = (right - left) * bytes_per_pixel
+            left_offset = left * bytes_per_pixel
+            generator = self.__image_converter(image)
+            for row_offset in range(left_offset + top * image_bytes_per_row,
+                                    left_offset + bottom * image_bytes_per_row,
+                                    image_bytes_per_row):
+                file_handle.seek(row_offset)
+                file_handle.write(bytes(islice(generator, segment_bytes_per_row)))
+
+        file_handle.flush()


Test patch:
diff --git a/tests/test_linux_framebuffer_device.py b/tests/test_linux_framebuffer_device.py
index dac72be..e7c66fa 100644
--- a/tests/test_linux_framebuffer_device.py
+++ b/tests/test_linux_framebuffer_device.py
@@ -10,38 +10,41 @@ import os
 import pytest
 
 from luma.core.render import canvas
+from luma.core.framebuffer import full_frame
 from luma.core.device import linux_framebuffer
 import luma.core.error
 
 from helpers import multi_mock_open, get_reference_file
 from unittest.mock import patch, call
 
-SCREEN_RES = "124,55"
+WIDTH = 124
+HEIGHT = 55
+SCREEN_RES = f"{WIDTH},{HEIGHT}"
 BITS_PER_PIXEL = "24"
 
 
 def test_display_id_as_dev_fb_number():
-    with patch("builtins.open", multi_mock_open(SCREEN_RES, BITS_PER_PIXEL)):
+    with patch("builtins.open", multi_mock_open(SCREEN_RES, BITS_PER_PIXEL, None)):
         device = linux_framebuffer("/dev/fb9")
         assert device.id == 9
 
 
 def test_display_id_from_environ():
     os.environ["FRAMEBUFFER"] = "/dev/fb16"
-    with patch("builtins.open", multi_mock_open(SCREEN_RES, BITS_PER_PIXEL)):
+    with patch("builtins.open", multi_mock_open(SCREEN_RES, BITS_PER_PIXEL, None)):
         device = linux_framebuffer()
         assert device.id == 16
 
 
 def test_unknown_display_id():
-    with patch("builtins.open", multi_mock_open(SCREEN_RES, BITS_PER_PIXEL)):
+    with patch("builtins.open", multi_mock_open(SCREEN_RES, BITS_PER_PIXEL, None)):
         with pytest.raises(luma.core.error.DeviceNotFoundError):
             linux_framebuffer("invalid fb")
 
 
 def test_read_screen_resolution():
     with patch(
-        "builtins.open", multi_mock_open(SCREEN_RES, BITS_PER_PIXEL)
+        "builtins.open", multi_mock_open(SCREEN_RES, BITS_PER_PIXEL, None)
     ) as fake_open:
         device = linux_framebuffer("/dev/fb1")
         assert device.width == 124
@@ -51,7 +54,7 @@ def test_read_screen_resolution():
 
 def test_read_bits_per_pixel():
     with patch(
-        "builtins.open", multi_mock_open(SCREEN_RES, BITS_PER_PIXEL)
+        "builtins.open", multi_mock_open(SCREEN_RES, BITS_PER_PIXEL, None)
     ) as fake_open:
         device = linux_framebuffer("/dev/fb1")
         assert device.bits_per_pixel == 24
@@ -65,36 +68,64 @@ def test_display_16bpp():
         reference = fp.read()
 
     with patch("builtins.open", multi_mock_open(SCREEN_RES, "16", None)) as fake_open:
-        device = linux_framebuffer("/dev/fb1")
+        device = linux_framebuffer("/dev/fb1", framebuffer=full_frame())
+
+        fake_open.assert_has_calls([call("/dev/fb1", "wb")])
+        fake_open.reset_mock()
+
         with canvas(device, dither=True) as draw:
             draw.rectangle((0, 0, 64, 32), fill="red")
             draw.rectangle((64, 0, 128, 32), fill="yellow")
             draw.rectangle((0, 32, 64, 64), fill="orange")
             draw.rectangle((64, 32, 128, 64), fill="white")
 
-    fake_open.assert_has_calls([call("/dev/fb1", "wb")])
-    fake_open.return_value.write.assert_called_once_with(reference)
+        fake_open.return_value.seek.assert_has_calls([
+            call(n * WIDTH * 2)
+            for n in range(HEIGHT)
+        ])
+        fake_open.return_value.write.assert_has_calls([
+            call(reference[n:n + (WIDTH * 2)])
+            for n in range(0, len(reference), WIDTH * 2)
+        ])
+        fake_open.return_value.flush.assert_called_once()
 
 
 def test_display_24bpp():
     with open(get_reference_file("fb_24bpp.raw"), "rb") as fp:
         reference = fp.read()
 
-    with patch("builtins.open", multi_mock_open(SCREEN_RES, "24", None)) as fake_open:
-        device = linux_framebuffer("/dev/fb1")
+    with patch("builtins.open", multi_mock_open(SCREEN_RES, BITS_PER_PIXEL, None)) as fake_open:
+        device = linux_framebuffer("/dev/fb1", framebuffer=full_frame())
+
+        fake_open.assert_has_calls([call("/dev/fb1", "wb")])
+        fake_open.reset_mock()
+
         with canvas(device, dither=True) as draw:
             draw.rectangle((0, 0, 64, 32), fill="red")
             draw.rectangle((64, 0, 128, 32), fill="yellow")
             draw.rectangle((0, 32, 64, 64), fill="orange")
             draw.rectangle((64, 32, 128, 64), fill="white")
 
-    fake_open.assert_has_calls([call("/dev/fb1", "wb")])
-    fake_open.return_value.write.assert_called_once_with(reference)
+        fake_open.return_value.seek.assert_has_calls([
+            call(n * WIDTH * 3)
+            for n in range(HEIGHT)
+        ])
+        fake_open.return_value.write.assert_has_calls([
+            call(reference[n:n + (WIDTH * 3)])
+            for n in range(0, len(reference), WIDTH * 3)
+        ])
+        fake_open.return_value.flush.assert_called_once()
 
 
 def test_unsupported_bit_depth():
-
     with patch("builtins.open", multi_mock_open(SCREEN_RES, "32", None)):
         with pytest.raises(AssertionError) as ex:
             linux_framebuffer("/dev/fb4")
         assert str(ex.value) == 'Unsupported bit-depth: 32'
+
+
+def test_cleanup():
+    with patch("builtins.open", multi_mock_open(SCREEN_RES, BITS_PER_PIXEL, None)) as fake_open:
+        device = linux_framebuffer("/dev/fb1", framebuffer=full_frame())
+        device.cleanup()
+        fake_open.return_value.close.assert_called_once()