From 0d243252f0fb3a3e200df404884e3cc7d872ee13 Mon Sep 17 00:00:00 2001 From: Melissa LeBlanc-Williams Date: Thu, 21 Sep 2023 18:35:31 -0700 Subject: [PATCH] More displayio code updates --- displayio/__init__.py | 6 +- displayio/_area.py | 18 ++- displayio/_colorconverter.py | 148 +++++++++---------- displayio/_display.py | 198 +++++++++++++++----------- displayio/_displaycore.py | 166 ++++++++++++++++++++-- displayio/_group.py | 14 +- displayio/_helpers.py | 29 ++++ displayio/_palette.py | 87 ++++++++---- displayio/_structs.py | 41 ++++++ displayio/_tilegrid.py | 268 +++++++++++++++++++++-------------- 10 files changed, 660 insertions(+), 315 deletions(-) create mode 100644 displayio/_helpers.py diff --git a/displayio/__init__.py b/displayio/__init__.py index f357dfa..74facd9 100644 --- a/displayio/__init__.py +++ b/displayio/__init__.py @@ -44,7 +44,7 @@ display_buses = [] def _background(): """Main thread function to loop through all displays and update them""" for display in displays: - display._background() # pylint: disable=protected-access + display.background() def release_displays() -> None: @@ -54,11 +54,11 @@ def release_displays() -> None: initialization so the display is active as long as possible. """ for display in displays: - display._release() # pylint: disable=protected-access + display.release() displays.clear() for display_bus in display_buses: - display_bus._release() # pylint: disable=protected-access + display_bus.deinit() display_buses.clear() diff --git a/displayio/_area.py b/displayio/_area.py index 3eba700..5b96e93 100644 --- a/displayio/_area.py +++ b/displayio/_area.py @@ -25,9 +25,9 @@ __repo__ = "https://github.com/adafruit/Adafruit_Blinka_Displayio.git" class Area: - # pylint: disable=invalid-name,missing-function-docstring - """Area Class to represent an area to be updated. Currently not used.""" + """Area Class to represent an area to be updated.""" + # pylint: disable=invalid-name def __init__(self, x1: int = 0, y1: int = 0, x2: int = 0, y2: int = 0): self.x1 = x1 self.y1 = y1 @@ -56,7 +56,8 @@ class Area: self.x2 += dx self.y2 += dy - def _compute_overlap(self, other, overlap) -> bool: + def compute_overlap(self, other, overlap) -> bool: + """Compute the overlap between two areas. Returns True if there is an overlap.""" a = self overlap.x1 = max(a.x1, other.x1) overlap.x2 = min(a.x2, other.x2) @@ -79,12 +80,11 @@ class Area: self.y1, self.y2 = self.y2, self.y1 def _union(self, other, union): - # pylint: disable=protected-access if self._empty(): self._copy_into(union) return - if other._empty(): - other._copy_into(union) + if other._empty(): # pylint: disable=protected-access + other._copy_into(union) # pylint: disable=protected-access return union.x1 = min(self.x1, other.x1) @@ -93,12 +93,15 @@ class Area: union.y2 = max(self.y2, other.y2) def width(self) -> int: + """Return the width of the area.""" return self.x2 - self.x1 def height(self) -> int: + """Return the height of the area.""" return self.y2 - self.y1 def size(self) -> int: + """Return the size of the area.""" return self.width() * self.height() def __eq__(self, other): @@ -113,7 +116,7 @@ class Area: ) @staticmethod - def _transform_within( + def transform_within( mirror_x: bool, mirror_y: bool, transpose_xy: bool, @@ -121,6 +124,7 @@ class Area: whole: Area, transformed: Area, ): + """Transform an area within a larger area.""" # pylint: disable=too-many-arguments # Original and whole must be in the same coordinate space. if mirror_x: diff --git a/displayio/_colorconverter.py b/displayio/_colorconverter.py index c3c5481..db57b6a 100644 --- a/displayio/_colorconverter.py +++ b/displayio/_colorconverter.py @@ -21,7 +21,8 @@ __version__ = "0.0.0+auto.0" __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git" from ._colorspace import Colorspace -from ._structs import ColorspaceStruct +from ._structs import ColorspaceStruct, InputPixelStruct, OutputPixelStruct +from ._helpers import clamp, bswap16 class ColorConverter: @@ -46,23 +47,16 @@ class ColorConverter: self._cached_output_color = None @staticmethod - def _clamp(value, min_value, max_value): - return max(min(max_value, value), min_value) - - @staticmethod - def _bswap16(value): - # ABCD -> 00DC - return (value & 0xFF00) >> 8 | (value & 0x00FF) << 8 - - def _dither_noise_1(self, noise): + def _dither_noise_1(noise): noise = (noise >> 13) ^ noise more_noise = ( noise * (noise * noise * 60493 + 19990303) + 1376312589 ) & 0x7FFFFFFF - return self._clamp(int((more_noise / (1073741824.0 * 2)) * 255), 0, 0xFFFFFFFF) + return clamp(int((more_noise / (1073741824.0 * 2)) * 255), 0, 0xFFFFFFFF) - def _dither_noise_2(self, x, y): - return self._dither_noise_1(x + y * 0xFFFF) + @staticmethod + def _dither_noise_2(x, y): + return ColorConverter._dither_noise_1(x + y * 0xFFFF) @staticmethod def _compute_rgb565(color_rgb888: int): @@ -120,11 +114,12 @@ class ColorConverter: return hue - def _compute_sevencolor(self, color_rgb888: int): + @staticmethod + def _compute_sevencolor(color_rgb888: int): # pylint: disable=too-many-return-statements - chroma = self._compute_chroma(color_rgb888) + chroma = ColorConverter._compute_chroma(color_rgb888) if chroma >= 64: - hue = self._compute_hue(color_rgb888) + hue = ColorConverter._compute_hue(color_rgb888) # Red 0 if hue < 10: return 0x4 @@ -142,7 +137,7 @@ class ColorConverter: return 0x3 # The rest is red to 255 return 0x4 - luma = self._compute_luma(color_rgb888) + luma = ColorConverter._compute_luma(color_rgb888) if luma >= 128: return 0x1 # White return 0x0 # Black @@ -173,51 +168,54 @@ class ColorConverter: else: raise ValueError("Color must be an integer or 3 or 4 value tuple") - input_pixel = { - "pixel": color, - "x": 0, - "y": 0, - "tile": 0, - "tile_x": 0, - "tile_y": 0, - } + input_pixel = InputPixelStruct(color) + output_pixel = OutputPixelStruct() - output_pixel = {"pixel": 0, "opaque": False} + self._convert(self._output_colorspace, input_pixel, output_pixel) - if input_pixel["pixel"] == self._transparent_color: - return output_pixel["pixel"] + return output_pixel.pixel + + def _convert( + self, + colorspace: Colorspace, + input_pixel: InputPixelStruct, + output_color: OutputPixelStruct, + ) -> None: + pixel = input_pixel.pixel + + if self._transparent_color == pixel: + output_color.opaque = False + return if ( not self._dither and self._cached_colorspace == self._output_colorspace - and self._cached_input_pixel == input_pixel["pixel"] + and self._cached_input_pixel == input_pixel.pixel ): - return self._cached_output_color + output_color = self._cached_output_color + return rgb888_pixel = input_pixel - rgb888_pixel["pixel"] = self._convert_pixel( - self._input_colorspace, input_pixel["pixel"] - ) - self._convert_color( - self._output_colorspace, self._dither, rgb888_pixel, output_pixel + rgb888_pixel.pixel = self._convert_pixel( + self._input_colorspace, input_pixel.pixel ) + self._convert_color(colorspace, self._dither, rgb888_pixel, output_color) if not self._dither: - self._cached_colorspace = self._output_colorspace - self._cached_input_pixel = input_pixel["pixel"] - self._cached_output_color = output_pixel["pixel"] - - return output_pixel["pixel"] + self._cached_colorspace = colorspace + self._cached_input_pixel = input_pixel.pixel + self._cached_output_color = output_color.pixel - def _convert_pixel(self, colorspace: Colorspace, pixel: int) -> int: - pixel = self._clamp(pixel, 0, 0xFFFFFFFF) + @staticmethod + def _convert_pixel(colorspace: Colorspace, pixel: int) -> int: + pixel = clamp(pixel, 0, 0xFFFFFFFF) if colorspace in ( Colorspace.RGB565_SWAPPED, Colorspace.RGB555_SWAPPED, Colorspace.BGR565_SWAPPED, Colorspace.BGR555_SWAPPED, ): - pixel = self._bswap16(pixel) + pixel = bswap16(pixel) if colorspace in (Colorspace.RGB565, Colorspace.RGB565_SWAPPED): red8 = (pixel >> 11) << 3 grn8 = ((pixel >> 5) << 2) & 0xFF @@ -242,19 +240,19 @@ class ColorConverter: return (pixel & 0xFF) & 0x01010101 return pixel + @staticmethod def _convert_color( - self, colorspace: ColorspaceStruct, dither: bool, - input_pixel: dict, - output_color: dict, + input_pixel: InputPixelStruct, + output_color: OutputPixelStruct, ) -> None: # pylint: disable=too-many-return-statements, too-many-branches, too-many-statements - pixel = input_pixel["pixel"] + pixel = input_pixel.pixel if dither: - rand_red = self._dither_noise_2(input_pixel["x"], input_pixel["y"]) - rand_grn = self._dither_noise_2(input_pixel["x"] + 33, input_pixel["y"]) - rand_blu = self._dither_noise_2(input_pixel["x"], input_pixel["y"] + 33) + rand_red = ColorConverter._dither_noise_2(input_pixel.x, input_pixel.y) + rand_grn = ColorConverter._dither_noise_2(input_pixel.x + 33, input_pixel.y) + rand_blu = ColorConverter._dither_noise_2(input_pixel.x, input_pixel.y + 33) red8 = pixel >> 16 grn8 = (pixel >> 8) & 0xFF @@ -272,49 +270,51 @@ class ColorConverter: pixel = (red8 << 16) | (grn8 << 8) | blu8 if colorspace.depth == 16: - packed = self._compute_rgb565(pixel) + packed = ColorConverter._compute_rgb565(pixel) if colorspace.reverse_bytes_in_word: - packed = self._bswap16(packed) - output_color["pixel"] = packed - output_color["opaque"] = True + packed = bswap16(packed) + output_color.pixel = packed + output_color.opaque = True return if colorspace.tricolor: - output_color["pixel"] = self._compute_luma(pixel) >> (8 - colorspace.depth) - if self._compute_chroma(pixel) <= 16: + output_color.pixel = ColorConverter._compute_luma(pixel) >> ( + 8 - colorspace.depth + ) + if ColorConverter._compute_chroma(pixel) <= 16: if not colorspace.grayscale: - output_color["pixel"] = 0 - output_color["opaque"] = True + output_color.pixel = 0 + output_color.opaque = True return - pixel_hue = self._compute_hue(pixel) - output_color["pixel"] = self._compute_tricolor( - colorspace, pixel_hue, output_color["pixel"] + pixel_hue = ColorConverter._compute_hue(pixel) + output_color.pixel = ColorConverter._compute_tricolor( + colorspace, pixel_hue, output_color.pixel ) return if colorspace.grayscale and colorspace.depth <= 8: bitmask = (1 << colorspace.depth) - 1 - output_color["pixel"] = ( - self._compute_luma(pixel) >> colorspace.grayscale_bit + output_color.pixel = ( + ColorConverter._compute_luma(pixel) >> colorspace.grayscale_bit ) & bitmask - output_color["opaque"] = True + output_color.opaque = True return if colorspace.depth == 32: - output_color["pixel"] = pixel - output_color["opaque"] = True + output_color.pixel = pixel + output_color.opaque = True return if colorspace.depth == 8 and colorspace.grayscale: - packed = self._compute_rgb332(pixel) - output_color["pixel"] = packed - output_color["opaque"] = True + packed = ColorConverter._compute_rgb332(pixel) + output_color.pixel = packed + output_color.opaque = True return if colorspace.depth == 4: if colorspace.sevencolor: - packed = self._compute_sevencolor(pixel) + packed = ColorConverter._compute_sevencolor(pixel) else: - packed = self._compute_rgbd(pixel) - output_color["pixel"] = packed - output_color["opaque"] = True + packed = ColorConverter._compute_rgbd(pixel) + output_color.pixel = packed + output_color.opaque = True return - output_color["opaque"] = False + output_color.opaque = False def make_transparent(self, color: int) -> None: """Set the transparent color or index for the ColorConverter. This will diff --git a/displayio/_display.py b/displayio/_display.py index eb3655d..8e33803 100644 --- a/displayio/_display.py +++ b/displayio/_display.py @@ -20,10 +20,8 @@ displayio for Blinka import time import struct from typing import Optional -from dataclasses import astuple import digitalio from PIL import Image -import numpy import microcontroller import circuitpython_typing from ._displaycore import _DisplayCore @@ -31,6 +29,7 @@ from ._displaybus import _DisplayBus from ._colorconverter import ColorConverter from ._group import Group from ._structs import RectangleStruct +from ._area import Area from ._constants import ( CHIP_SELECT_TOGGLE_EVERY_BYTE, CHIP_SELECT_UNTOUCHED, @@ -163,8 +162,6 @@ class Display: self._initialize(init_sequence) self._buffer = Image.new("RGB", (width, height)) - self._subrectangles = [] - self._bounds_encoding = ">BB" if single_byte_bounds else ">HH" self._current_group = None self._last_refresh_call = 0 self._refresh_thread = None @@ -229,27 +226,14 @@ class Display: time.sleep(delay_time_ms / 1000) i += 2 + data_size - def _send(self, command, data): - self._core.begin_transaction() - if self._core.data_as_commands: - self._core.send( - DISPLAY_COMMAND, CHIP_SELECT_TOGGLE_EVERY_BYTE, bytes([command]) + data - ) - else: - self._core.send( - DISPLAY_COMMAND, CHIP_SELECT_TOGGLE_EVERY_BYTE, bytes([command]) - ) - self._core.send(DISPLAY_DATA, CHIP_SELECT_UNTOUCHED, data) - self._core.end_transaction() - - def _send_pixels(self, data): + def _send_pixels(self, pixels): if not self._core.data_as_commands: self._core.send( DISPLAY_COMMAND, CHIP_SELECT_TOGGLE_EVERY_BYTE, bytes([self._write_ram_command]), ) - self._core.send(DISPLAY_DATA, CHIP_SELECT_UNTOUCHED, data) + self._core.send(DISPLAY_DATA, CHIP_SELECT_UNTOUCHED, pixels) def show(self, group: Group) -> None: """Switches to displaying the given group of layers. When group is None, the @@ -310,6 +294,7 @@ class Display: if not self._core.start_refresh(): return False + # TODO: Likely move this to _refresh_area() # Go through groups and and add each to buffer if self._core.current_group is not None: buffer = Image.new("RGBA", (self._core.width, self._core.height)) @@ -320,16 +305,25 @@ class Display: # save image to buffer (or probably refresh buffer so we can compare) self._buffer.paste(buffer) - self._subrectangles = self._core.get_refresh_areas() + areas_to_refresh = self._get_refresh_areas() - for area in self._subrectangles: - self._refresh_display_area(area) + for area in areas_to_refresh: + self._refresh_area(area) self._core.finish_refresh() return True - def _background(self): + def _get_refresh_areas(self) -> list[Area]: + """Get a list of areas to be refreshed""" + areas = [] + if self._core.current_group is not None: + # Eventually calculate dirty rectangles here + areas.append(Area(0, 0, self._core.width, self._core.height)) + return areas + + def background(self): + """Run background refresh tasks. Do not call directly""" if ( self._auto_refresh and (time.monotonic() * 1000 - self._core.last_refresh) @@ -337,56 +331,73 @@ class Display: ): self.refresh() - def _refresh_display_area(self, rectangle): - """Loop through dirty rectangles and redraw that area.""" - img = self._buffer.convert("RGB").crop(astuple(rectangle)) - img = img.rotate(360 - self._core.rotation, expand=True) - - display_rectangle = self._apply_rotation(rectangle) - img = img.crop(astuple(self._clip(display_rectangle))) - - data = numpy.array(img).astype("uint16") - color = ( - ((data[:, :, 0] & 0xF8) << 8) - | ((data[:, :, 1] & 0xFC) << 3) - | (data[:, :, 2] >> 3) - ) - - pixels = bytes( - numpy.dstack(((color >> 8) & 0xFF, color & 0xFF)).flatten().tolist() - ) - - self._send( - self._core.column_command, - self._encode_pos( - display_rectangle.x1 + self._core.colstart, - display_rectangle.x2 + self._core.colstart - 1, - ), - ) - self._send( - self._core.row_command, - self._encode_pos( - display_rectangle.y1 + self._core.rowstart, - display_rectangle.y2 + self._core.rowstart - 1, - ), - ) - - self._core.begin_transaction() - self._send_pixels(pixels) - self._core.end_transaction() - - def _clip(self, rectangle): - if self._core.rotation in (90, 270): - width, height = self._core.height, self._core.width - else: - width, height = self._core.width, self._core.height + def _refresh_area(self, area) -> bool: + """Loop through dirty areas and redraw that area.""" + # pylint: disable=too-many-locals + buffer_size = 128 + + clipped = Area() + if not self._core.clip_area(area, clipped): + return True + + rows_per_buffer = clipped.height() + pixels_per_word = (struct.calcsize("I") * 8) // self._core.colorspace.depth + pixels_per_buffer = clipped.size() + + subrectangles = 1 + + if self._core.sh1107_addressing: + subrectangles = rows_per_buffer // 8 + rows_per_buffer = 8 + elif clipped.size() > buffer_size * pixels_per_word: + rows_per_buffer = buffer_size * pixels_per_word // clipped.width() + if rows_per_buffer == 0: + rows_per_buffer = 1 + if ( + self._core.colorspace.depth < 8 + and self._core.colorspace.pixels_in_byte_share_row + ): + pixels_per_byte = 8 // self._core.colorspace.depth + if rows_per_buffer % pixels_per_byte != 0: + rows_per_buffer -= rows_per_buffer % pixels_per_byte + subrectangles = clipped.height() // rows_per_buffer + if clipped.height() % rows_per_buffer != 0: + subrectangles += 1 + pixels_per_buffer = rows_per_buffer * clipped.width() + buffer_size = pixels_per_buffer // pixels_per_word + if pixels_per_buffer % pixels_per_word: + buffer_size += 1 + + buffer = bytearray(buffer_size) + mask_length = (pixels_per_buffer // 32) + 1 + mask = bytearray(mask_length) + remaining_rows = clipped.height() + + for subrect_index in range(subrectangles): + subrectangle = Area( + clipped.x1, + clipped.y1 + rows_per_buffer * subrect_index, + clipped.x2, + clipped.y1 + rows_per_buffer * (subrect_index + 1), + ) + if remaining_rows < rows_per_buffer: + subrectangle.y2 = subrectangle.y1 + remaining_rows + self._core.set_region_to_update(subrectangle) + if self._core.colorspace.depth >= 8: + subrectangle_size_bytes = subrectangle.size() * ( + self._core.colorspace.depth // 8 + ) + else: + subrectangle_size_bytes = subrectangle.size() // ( + 8 // self._core.colorspace.depth + ) - rectangle.x1 = max(rectangle.x1, 0) - rectangle.y1 = max(rectangle.y1, 0) - rectangle.x2 = min(rectangle.x2, width) - rectangle.y2 = min(rectangle.y2, height) + self._core.fill_area(subrectangle, mask, buffer) - return rectangle + self._core.begin_transaction() + self._send_pixels(buffer[:subrectangle_size_bytes]) + self._core.end_transaction() + return True def _apply_rotation(self, rectangle): """Adjust the rectangle coordinates based on rotation""" @@ -413,10 +424,6 @@ class Display: ) return rectangle - def _encode_pos(self, x, y): - """Encode a postion into bytes.""" - return struct.pack(self._bounds_encoding, x, y) # pylint: disable=no-member - def fill_row( self, y: int, buffer: circuitpython_typing.WriteableBuffer ) -> circuitpython_typing.WriteableBuffer: @@ -427,6 +434,15 @@ class Display: buffer[x * 2 + 1] = _rgb_565 & 0xFF return buffer + def release(self) -> None: + """Release the display and free its resources""" + self.auto_refresh = False + self._core.release_display_core() + + def reset(self) -> None: + """Reset the display""" + self.auto_refresh = True + @property def auto_refresh(self) -> bool: """True when the display is refreshed automatically.""" @@ -434,6 +450,7 @@ class Display: @auto_refresh.setter def auto_refresh(self, value: bool): + self._first_manual_refresh = not value self._auto_refresh = value @property @@ -447,13 +464,32 @@ class Display: @brightness.setter def brightness(self, value: float): if 0 <= float(value) <= 1.0: - self._brightness = value - if self._backlight_type == BACKLIGHT_IN_OUT: - self._backlight.value = round(self._brightness) - elif self._backlight_type == BACKLIGHT_PWM: - self._backlight.duty_cycle = self._brightness * 65535 + if not self._backlight_on_high: + value = 1.0 - value + + if self._backlight_type == BACKLIGHT_PWM: + self._backlight.duty_cycle = value * 0xFFFF + elif self._backlight_type == BACKLIGHT_IN_OUT: + self._backlight.value = value > 0.99 elif self._brightness_command is not None: - self._send(self._brightness_command, round(value * 255)) + self._core.begin_transaction() + if self._core.data_as_commands: + self._core.send( + DISPLAY_COMMAND, + CHIP_SELECT_TOGGLE_EVERY_BYTE, + bytes([self._brightness_command, 0xFF * value]), + ) + else: + self._core.send( + DISPLAY_COMMAND, + CHIP_SELECT_TOGGLE_EVERY_BYTE, + bytes([self._brightness_command]), + ) + self._core.send( + DISPLAY_DATA, CHIP_SELECT_UNTOUCHED, round(value * 255) + ) + self._core.end_transaction() + self._brightness = value else: raise ValueError("Brightness must be between 0.0 and 1.0") diff --git a/displayio/_displaycore.py b/displayio/_displaycore.py index ccda898..3126b87 100644 --- a/displayio/_displaycore.py +++ b/displayio/_displaycore.py @@ -23,18 +23,27 @@ __repo__ = "https://github.com/adafruit/Adafruit_Blinka_Displayio.git" import time +import struct import circuitpython_typing from paralleldisplay import ParallelBus from ._fourwire import FourWire from ._group import Group from ._i2cdisplay import I2CDisplay -from ._structs import ColorspaceStruct, TransformStruct, RectangleStruct +from ._structs import ColorspaceStruct, TransformStruct from ._area import Area from ._displaybus import _DisplayBus +from ._helpers import bswap16 +from ._constants import ( + CHIP_SELECT_UNTOUCHED, + CHIP_SELECT_TOGGLE_EVERY_BYTE, + DISPLAY_COMMAND, + DISPLAY_DATA, + NO_COMMAND, +) class _DisplayCore: - # pylint: disable=too-many-arguments, too-many-instance-attributes, too-many-locals + # pylint: disable=too-many-arguments, too-many-instance-attributes, too-many-locals, too-many-branches, too-many-statements def __init__( self, @@ -229,15 +238,7 @@ class _DisplayCore: self.refresh_in_progress = False self.last_refresh = time.monotonic() * 1000 - def get_refresh_areas(self) -> list: - """Get a list of areas to be refreshed""" - subrectangles = [] - if self.current_group is not None: - # Eventually calculate dirty rectangles here - subrectangles.append(RectangleStruct(0, 0, self.width, self.height)) - return subrectangles - - def release(self) -> None: + def release_display_core(self) -> None: """Release the display from the current group""" # pylint: disable=protected-access @@ -250,16 +251,32 @@ class _DisplayCore: mask: circuitpython_typing.WriteableBuffer, buffer: circuitpython_typing.WriteableBuffer, ) -> bool: - # pylint: disable=protected-access """Call the current group's fill area function""" + if self.current_group is not None: + return self.current_group._fill_area( # pylint: disable=protected-access + self.colorspace, area, mask, buffer + ) + return False - return self.current_group._fill_area(self.colorspace, area, mask, buffer) + """ + def _clip(self, rectangle): + if self._core.rotation in (90, 270): + width, height = self._core.height, self._core.width + else: + width, height = self._core.width, self._core.height + + rectangle.x1 = max(rectangle.x1, 0) + rectangle.y1 = max(rectangle.y1, 0) + rectangle.x2 = min(rectangle.x2, width) + rectangle.y2 = min(rectangle.y2, height) + + return rectangle + """ def clip_area(self, area: Area, clipped: Area) -> bool: """Shrink the area to the region shared by the two areas""" - # pylint: disable=protected-access - overlaps = self.area._compute_overlap(area, clipped) + overlaps = self.area.compute_overlap(area, clipped) if not overlaps: return False @@ -281,6 +298,125 @@ class _DisplayCore: return True + def set_region_to_update(self, area: Area) -> None: + """Set the region to update""" + region_x1 = area.x1 + self.colstart + region_x2 = area.x2 + self.colstart + region_y1 = area.y1 + self.rowstart + region_y2 = area.y2 + self.rowstart + + if self.colorspace.depth < 8: + pixels_per_byte = 8 // self.colorspace.depth + if self.colorspace.pixels_in_byte_share_row: + region_x1 /= pixels_per_byte * self.colorspace.bytes_per_cell + region_x2 /= pixels_per_byte * self.colorspace.bytes_per_cell + else: + region_y1 /= pixels_per_byte * self.colorspace.bytes_per_cell + region_y2 /= pixels_per_byte * self.colorspace.bytes_per_cell + + region_x2 -= 1 + region_y2 -= 1 + + chip_select = CHIP_SELECT_UNTOUCHED + if self.always_toggle_chip_select or self.data_as_commands: + chip_select = CHIP_SELECT_TOGGLE_EVERY_BYTE + + # Set column + self.begin_transaction() + data_type = DISPLAY_DATA + if not self.data_as_commands: + self.send( + DISPLAY_COMMAND, CHIP_SELECT_UNTOUCHED, bytes([self.column_command]) + ) + else: + data_type = DISPLAY_COMMAND + """ + self._core.send( + DISPLAY_COMMAND, CHIP_SELECT_TOGGLE_EVERY_BYTE, bytes([command]) + data + ) + self._core.send(DISPLAY_DATA, CHIP_SELECT_UNTOUCHED, data) + """ + + if self.ram_width < 0x100: # Single Byte Bounds + data = struct.pack(">BB", region_x1, region_x2) + else: + if self.address_little_endian: + region_x1 = bswap16(region_x1) + region_x2 = bswap16(region_x2) + data = struct.pack(">HH", region_x1, region_x2) + + # Quirk for SH1107 "SH1107_addressing" + # Column lower command = 0x00, Column upper command = 0x10 + if self.sh1107_addressing: + data = struct.pack( + ">BB", + ((region_x1 >> 4) & 0xF0) | 0x10, # 0x10 to 0x17 + region_x1 & 0x0F, # 0x00 to 0x0F + ) + + self.send(data_type, chip_select, data) + self.end_transaction() + + if self.set_current_column_command != NO_COMMAND: + self.begin_transaction() + self.send( + DISPLAY_COMMAND, chip_select, bytes([self.set_current_column_command]) + ) + # Only send the first half of data because it is the first coordinate. + self.send(DISPLAY_DATA, chip_select, data[: len(data) // 2]) + self.end_transaction() + + # Set row + self.begin_transaction() + + if not self.data_as_commands: + self.send(DISPLAY_COMMAND, CHIP_SELECT_UNTOUCHED, bytes([self.row_command])) + + if self.ram_width < 0x100: # Single Byte Bounds + data = struct.pack(">BB", region_y1, region_y2) + else: + if self.address_little_endian: + region_y1 = bswap16(region_y1) + region_y2 = bswap16(region_y2) + data = struct.pack(">HH", region_y1, region_y2) + + # Quirk for SH1107 "SH1107_addressing" + # Page address command = 0xB0 + if self.sh1107_addressing: + data = struct.pack(">B", 0xB0 | region_y1) + + self.send(data_type, chip_select, data) + self.end_transaction() + + if self.set_current_row_command != NO_COMMAND: + self.begin_transaction() + self.send( + DISPLAY_COMMAND, chip_select, bytes([self.set_current_row_command]) + ) + # Only send the first half of data because it is the first coordinate. + self.send(DISPLAY_DATA, chip_select, data[: len(data) // 2]) + self.end_transaction() + + """ + img = self._buffer.convert("RGB").crop(astuple(area)) + img = img.rotate(360 - self._core.rotation, expand=True) + + display_area = self._apply_rotation(area) + + img = img.crop(astuple(display_area)) + + data = numpy.array(img).astype("uint16") + color = ( + ((data[:, :, 0] & 0xF8) << 8) + | ((data[:, :, 1] & 0xFC) << 3) + | (data[:, :, 2] >> 3) + ) + + pixels = bytes( + numpy.dstack(((color >> 8) & 0xFF, color & 0xFF)).flatten().tolist() + ) + """ + def send( self, data_type: int, diff --git a/displayio/_group.py b/displayio/_group.py index 505a3bf..a23ae4e 100644 --- a/displayio/_group.py +++ b/displayio/_group.py @@ -21,6 +21,8 @@ from __future__ import annotations from typing import Union, Callable from ._structs import TransformStruct from ._tilegrid import TileGrid +from ._colorspace import Colorspace +from ._area import Area __version__ = "0.0.0+auto.0" __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git" @@ -141,13 +143,19 @@ class Group: """Deletes the value at the given index.""" del self._layers[index] - def _fill_area(self, buffer): + def _fill_area( + self, colorspace: Colorspace, area: Area, mask: int, buffer: bytearray + ) -> bool: if self._hidden_group: - return + return False for layer in self._layers: if isinstance(layer, (Group, TileGrid)): - layer._fill_area(buffer) # pylint: disable=protected-access + if layer._fill_area( # pylint: disable=protected-access + colorspace, area, mask, buffer + ): + return True + return False def sort(self, key: Callable, reverse: bool) -> None: """Sort the members of the group.""" diff --git a/displayio/_helpers.py b/displayio/_helpers.py new file mode 100644 index 0000000..cbff9f4 --- /dev/null +++ b/displayio/_helpers.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: 2023 Melissa LeBlanc-Williams for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +""" +`displayio.helpers` +================================================================================ + +displayio for Blinka + +**Software and Dependencies:** + +* Adafruit Blinka: + https://github.com/adafruit/Adafruit_Blinka/releases + +* Author(s): Melissa LeBlanc-Williams + +""" + +__version__ = "0.0.0+auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git" + +def clamp(value, min_value, max_value): + """Clamp a value between a minimum and maximum value""" + return max(min(max_value, value), min_value) + +def bswap16(value): + """Swap the bytes in a 16 bit value""" + return (value & 0xFF00) >> 8 | (value & 0x00FF) << 8 \ No newline at end of file diff --git a/displayio/_palette.py b/displayio/_palette.py index 969c769..2424db1 100644 --- a/displayio/_palette.py +++ b/displayio/_palette.py @@ -19,6 +19,9 @@ displayio for Blinka from typing import Optional, Union, Tuple import circuitpython_typing +from ._colorconverter import ColorConverter +from ._colorspace import Colorspace +from ._structs import InputPixelStruct, OutputPixelStruct, ColorStruct __version__ = "0.0.0+auto.0" __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git" @@ -29,31 +32,18 @@ class Palette: format internally to save memory. """ - def __init__(self, color_count: int): + def __init__(self, color_count: int, dither: bool = False): """Create a Palette object to store a set number of colors.""" self._needs_refresh = False + self._dither = dither self._colors = [] for _ in range(color_count): self._colors.append(self._make_color(0)) - self._update_rgba(len(self._colors) - 1) - - def _update_rgba(self, index): - color = self._colors[index]["rgb888"] - transparent = self._colors[index]["transparent"] - self._colors[index]["rgba"] = ( - color >> 16, - (color >> 8) & 0xFF, - color & 0xFF, - 0 if transparent else 0xFF, - ) def _make_color(self, value, transparent=False): - color = { - "transparent": transparent, - "rgb888": 0, - "rgba": (0, 0, 0, 255), - } + color = ColorStruct(transparent=transparent) + if isinstance(value, (tuple, list, bytes, bytearray)): value = (value[0] & 0xFF) << 16 | (value[1] & 0xFF) << 8 | value[2] & 0xFF elif isinstance(value, int): @@ -61,7 +51,7 @@ class Palette: raise ValueError("Color must be between 0x000000 and 0xFFFFFF") else: raise TypeError("Color buffer must be a buffer, tuple, list, or int") - color["rgb888"] = value + color.rgb888 = value self._needs_refresh = True return color @@ -82,30 +72,27 @@ class Palette: (to represent an RGB value). Value can be an int, bytes (3 bytes (RGB) or 4 bytes (RGB + pad byte)), bytearray, or a tuple or list of 3 integers. """ - if self._colors[index]["rgb888"] != value: + if self._colors[index].rgb888 != value: self._colors[index] = self._make_color(value) - self._update_rgba(index) def __getitem__(self, index: int) -> Optional[int]: if not 0 <= index < len(self._colors): raise ValueError("Palette index out of range") - return self._colors[index]["rgb888"] + return self._colors[index].rgb888 def make_transparent(self, palette_index: int) -> None: """Set the palette index to be a transparent color""" - self._colors[palette_index]["transparent"] = True - self._update_rgba(palette_index) + self._colors[palette_index].transparent = True def make_opaque(self, palette_index: int) -> None: """Set the palette index to be an opaque color""" - self._colors[palette_index]["transparent"] = False - self._update_rgba(palette_index) + self._colors[palette_index].transparent = False def _get_palette(self): """Generate a palette for use with PIL""" palette = [] for color in self._colors: - palette += color["rgba"][0:3] + palette += color.rgba()[0:3] return palette def _get_alpha_palette(self): @@ -114,9 +101,53 @@ class Palette: palette = [] for color in self._colors: for _ in range(3): - palette += [0 if color["transparent"] else 255] + palette += [0 if color.transparent else 0xFF] return palette + def _get_color( + self, + colorspace: Colorspace, + input_pixel: InputPixelStruct, + output_color: OutputPixelStruct, + ): + palette_index = input_pixel.pixel + if palette_index > len(self._colors) or self._colors[palette_index].transparent: + output_color.opaque = False + return + + color = self._colors[palette_index] + if ( + not self._dither + and color.cached_colorspace == colorspace + and color.cached_colorspace_grayscale_bit == colorspace.grayscale_bit + and color.cached_colorspace_grayscale == colorspace.grayscale + ): + output_color.pixel = self._colors[palette_index].cached_color + return + + rgb888_pixel = input_pixel + ColorConverter._convert_color( # pylint: disable=protected-access + colorspace, self._dither, rgb888_pixel, output_color + ) + if not self._dither: + color.cached_colorspace = colorspace + color.cached_color = output_color.pixel + color.cached_colorspace_grayscale = colorspace.grayscale + color.cached_colorspace_grayscale_bit = colorspace.grayscale_bit + def is_transparent(self, palette_index: int) -> bool: """Returns True if the palette index is transparent. Returns False if opaque.""" - return self._colors[palette_index]["transparent"] + return self._colors[palette_index].transparent + + @property + def dither(self) -> bool: + """When true the palette dithers the output by adding + random noise when truncating to display bitdepth + """ + return self._dither + + @dither.setter + def dither(self, value: bool): + if not isinstance(value, bool): + raise ValueError("Value should be boolean") + self._dither = value diff --git a/displayio/_structs.py b/displayio/_structs.py index 9a2017e..a46fb48 100644 --- a/displayio/_structs.py +++ b/displayio/_structs.py @@ -63,3 +63,44 @@ class ColorspaceStruct: reverse_pixels_in_byte: bool = False reverse_bytes_in_word: bool = False dither: bool = False + + +@dataclass +class InputPixelStruct: + """InputPixel Struct Dataclass""" + + pixel: int = 0 + x: int = 0 + y: int = 0 + tile: int = 0 + tile_x: int = 0 + tile_y: int = 0 + + +@dataclass +class OutputPixelStruct: + """OutputPixel Struct Dataclass""" + + pixel: int = 0 + opaque: bool = False + + +@dataclass +class ColorStruct: + """Color Struct Dataclass""" + + rgb888: int = 0 + cached_colorspace: ColorspaceStruct = None + cached_color: int = 0 + cached_colorspace_grayscale_bit: int = 0 + cached_colorspace_grayscale: bool = False + transparent: bool = False + + def rgba(self) -> tuple[int, int, int, int]: + """Return the color as a tuple of red, green, blue, alpha""" + return ( + self.rgb888 >> 16, + (self.rgb888 >> 8) & 0xFF, + self.rgb888 & 0xFF, + 0 if self.transparent else 0xFF, + ) diff --git a/displayio/_tilegrid.py b/displayio/_tilegrid.py index 41ff504..0cfc6d3 100644 --- a/displayio/_tilegrid.py +++ b/displayio/_tilegrid.py @@ -17,14 +17,16 @@ displayio for Blinka """ +import struct from typing import Union, Optional, Tuple -from PIL import Image from ._bitmap import Bitmap from ._colorconverter import ColorConverter from ._ondiskbitmap import OnDiskBitmap from ._shape import Shape from ._palette import Palette -from ._structs import RectangleStruct, TransformStruct +from ._structs import TransformStruct, InputPixelStruct, OutputPixelStruct +from ._colorspace import Colorspace +from ._area import Area __version__ = "0.0.0+auto.0" __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git" @@ -73,8 +75,8 @@ class TileGrid: self._hidden_tilegrid = False self._x = x self._y = y - self._width = width # Number of Tiles Wide - self._height = height # Number of Tiles High + self._width_in_tiles = width + self._height_in_tiles = height self._transpose_xy = False self._flip_x = False self._flip_y = False @@ -96,13 +98,16 @@ class TileGrid: raise ValueError("Default Tile is out of range") self._pixel_width = width * tile_width self._pixel_height = height * tile_height - self._tiles = (self._width * self._height) * [default_tile] + self._tiles = (self._width_in_tiles * self._height_in_tiles) * [default_tile] self._in_group = False self._absolute_transform = TransformStruct(0, 0, 1, 1, 1, False, False, False) - self._current_area = RectangleStruct( - 0, 0, self._pixel_width, self._pixel_height - ) + self._current_area = Area(0, 0, self._pixel_width, self._pixel_height) self._moved = False + self._bitmap_width_in_tiles = bitmap_width // tile_width + self._tiles_in_bitmap = self._bitmap_width_in_tiles * ( + bitmap_height // tile_height + ) + self.inline_tiles = False # We have plenty of memory def _update_transform(self, absolute_transform): """Update the parent transform and child transforms""" @@ -196,104 +201,155 @@ class TileGrid: ) image.putalpha(alpha.convert("L")) - def _fill_area(self, buffer): - # pylint: disable=too-many-locals,too-many-branches,too-many-statements + def _fill_area( + self, colorspace: Colorspace, area: Area, mask: bytearray, buffer: bytearray + ) -> bool: """Draw onto the image""" - if self._hidden_tilegrid: - return + # pylint: disable=too-many-locals,too-many-branches,too-many-statements - if self._bitmap.width <= 0 or self._bitmap.height <= 0: - return - - # Copy class variables to local variables in case something changes - x = self._x - y = self._y - width = self._width - height = self._height - tile_width = self._tile_width - tile_height = self._tile_height - bitmap_width = self._bitmap.width - pixel_width = self._pixel_width - pixel_height = self._pixel_height - tiles = self._tiles - absolute_transform = self._absolute_transform - pixel_shader = self._pixel_shader - bitmap = self._bitmap + # If no tiles are present we have no impact tiles = self._tiles - tile_count_x = bitmap_width // tile_width + if self._hidden_tilegrid: + return False - image = Image.new( - "RGBA", - (width * tile_width, height * tile_height), - (0, 0, 0, 0), - ) + overlap = Area() + if not self._current_area.compute_overlap(area, overlap): + return False - for tile_x in range(width): - for tile_y in range(height): - tile_index = tiles[tile_y * width + tile_x] - tile_index_x = tile_index % tile_count_x - tile_index_y = tile_index // tile_count_x - tile_image = bitmap._image # pylint: disable=protected-access - if isinstance(pixel_shader, Palette): - tile_image = tile_image.copy().convert("P") - self._apply_palette(tile_image) - tile_image = tile_image.convert("RGBA") - self._add_alpha(tile_image) - elif isinstance(pixel_shader, ColorConverter): - # This will be needed for eInks, grayscale, and monochrome displays - pass - image.alpha_composite( - tile_image, - dest=(tile_x * tile_width, tile_y * tile_height), - source=( - tile_index_x * tile_width, - tile_index_y * tile_height, - tile_index_x * tile_width + tile_width, - tile_index_y * tile_height + tile_height, - ), - ) + if self._bitmap.width <= 0 or self._bitmap.height <= 0: + return False + + x_stride = 1 + y_stride = area.width() + + flip_x = self._flip_x + flip_y = self._flip_y + if self._transpose_xy != self._absolute_transform.transpose_xy: + flip_x, flip_y = flip_y, flip_x + + start = 0 + if (self._absolute_transform.dx < 0) != flip_x: + start += (area.width() - 1) * x_stride + x_stride *= -1 + if (self._absolute_transform.dy < 0) != flip_y: + start += (area.height() - 1) * y_stride + y_stride *= -1 + + full_coverage = area == overlap + + transformed = Area() + area.transform_within( + flip_x != (self._absolute_transform.dx < 0), + flip_y != (self._absolute_transform.dy < 0), + self.transpose_xy != self._absolute_transform.transpose_xy, + overlap, + self._current_area, + transformed, + ) - if absolute_transform is not None: - if absolute_transform.scale > 1: - image = image.resize( - ( - int(pixel_width * absolute_transform.scale), - int( - pixel_height * absolute_transform.scale, - ), - ), - resample=Image.NEAREST, - ) - if absolute_transform.mirror_x != self._flip_x: - image = image.transpose(Image.FLIP_LEFT_RIGHT) - if absolute_transform.mirror_y != self._flip_y: - image = image.transpose(Image.FLIP_TOP_BOTTOM) - if absolute_transform.transpose_xy != self._transpose_xy: - image = image.transpose(Image.TRANSPOSE) - x *= absolute_transform.dx - y *= absolute_transform.dy - x += absolute_transform.x - y += absolute_transform.y - - source_x = source_y = 0 - if x < 0: - source_x = round(0 - x) - x = 0 - if y < 0: - source_y = round(0 - y) - y = 0 - - x = round(x) - y = round(y) + start_x = transformed.x1 - self._current_area.x1 + end_x = transformed.x2 - self._current_area.x1 + start_y = transformed.y1 - self._current_area.y1 + end_y = transformed.y2 - self._current_area.y1 - if ( - x <= buffer.width - and y <= buffer.height - and source_x <= image.width - and source_y <= image.height - ): - buffer.alpha_composite(image, (x, y), source=(source_x, source_y)) + if (self._absolute_transform.dx < 0) != flip_x: + x_shift = area.x2 - overlap.x2 + else: + x_shift = overlap.x1 - area.x1 + if (self._absolute_transform.dy < 0) != flip_y: + y_shift = area.y2 - overlap.y2 + else: + y_shift = overlap.y1 - area.y1 + + if self._transpose_xy != self._absolute_transform.transpose_xy: + x_stride, y_stride = y_stride, x_stride + x_shift, y_shift = y_shift, x_shift + + pixels_per_byte = 8 // colorspace.depth + + input_pixel = InputPixelStruct() + output_pixel = OutputPixelStruct() + for input_pixel.y in range(start_y, end_y): + row_start = ( + start + (input_pixel.y - start_y + y_shift) * y_stride + ) # In Pixels + local_y = input_pixel.y // self._absolute_transform.scale + for input_pixel.x in range(start_x, end_x): + offset = ( + row_start + (input_pixel.x - start_x + x_shift) * x_stride + ) # In Pixels + + # Check the mask first to see if the pixel has already been set + if mask[offset // 32] & (1 << (offset % 32)): + continue + local_x = input_pixel.x // self._absolute_transform.scale + tile_location = ( + (local_y // self._tile_height + self._top_left_y) + % self._height_in_tiles + ) * self._width_in_tiles + ( + local_x // self._tile_width + self._top_left_x + ) % self._width_in_tiles + input_pixel.tile = tiles[tile_location] + input_pixel.tile_x = ( + input_pixel.tile % self._bitmap_width_in_tiles + ) * self._tile_width + local_x % self._tile_width + input_pixel.tile_y = ( + input_pixel.tile // self._bitmap_width_in_tiles + ) * self._tile_height + local_y % self._tile_height + + input_pixel.pixel = self.bitmap[input_pixel.tile_x, input_pixel.tile_y] + output_pixel.opaque = True + + if self._pixel_shader is None: + output_pixel.pixel = input_pixel.pixel + elif isinstance(self._pixel_shader, Palette): + self._pixel_shader._get_color( # pylint: disable=protected-access + colorspace, input_pixel, output_pixel + ) + elif isinstance(self._pixel_shader, ColorConverter): + self._pixel_shader._convert( # pylint: disable=protected-access + colorspace, input_pixel, output_pixel + ) + + if not output_pixel.opaque: + full_coverage = False + else: + mask[offset // 32] |= 1 << (offset % 32) + if colorspace.depth == 16: + buffer = ( + buffer[:offset] + + struct.pack("H", output_pixel.pixel) + + buffer[offset + 2 :] + ) + elif colorspace.depth == 32: + buffer = ( + buffer[:offset] + + struct.pack("I", output_pixel.pixel) + + buffer[offset + 4 :] + ) + elif colorspace.depth == 8: + buffer[offset] = output_pixel.pixel & 0xFF + elif colorspace.depth < 8: + # Reorder the offsets to pack multiple rows into + # a byte (meaning they share a column). + if not colorspace.pixels_in_byte_share_row: + width = area.width() + row = offset // width + col = offset % width + # Dividing by pixels_per_byte does truncated division + # even if we multiply it back out + offset = ( + col * pixels_per_byte + + (row // pixels_per_byte) * width + + (row % pixels_per_byte) + ) + shift = (offset % pixels_per_byte) * colorspace.depth + if colorspace.reverse_pixels_in_byte: + # Reverse the shift by subtracting it from the leftmost shift + shift = (pixels_per_byte - 1) * colorspace.depth - shift + buffer[offset // pixels_per_byte] |= output_pixel.pixel << shift + return full_coverage def _finish_refresh(self): pass @@ -420,11 +476,15 @@ class TileGrid: if isinstance(index, (tuple, list)): x = index[0] y = index[1] - index = y * self._width + x + index = y * self._width_in_tiles + x elif isinstance(index, int): - x = index % self._width - y = index // self._width - if x > self._width or y > self._height or index >= len(self._tiles): + x = index % self._width_in_tiles + y = index // self._width_in_tiles + if ( + x > self._width_in_tiles + or y > self._height_in_tiles + or index >= len(self._tiles) + ): raise ValueError("Tile index out of bounds") return index @@ -447,12 +507,12 @@ class TileGrid: @property def width(self) -> int: """Width in tiles""" - return self._width + return self._width_in_tiles @property def height(self) -> int: """Height in tiles""" - return self._height + return self._height_in_tiles @property def tile_width(self) -> int: -- 2.49.0