X-Git-Url: https://git.ayoreis.com/hackapet/Adafruit_Blinka_Displayio.git/blobdiff_plain/dfb656fa551ff81f975009801f5079a406dec8e9..c0248c715a83b0310e880756acb10445f7f89f24:/displayio.py diff --git a/displayio.py b/displayio.py index 11b9e56..fb69401 100644 --- a/displayio.py +++ b/displayio.py @@ -5,12 +5,15 @@ import os import digitalio import time -from PIL import Image, ImageDraw +import struct +import threading +import numpy +from collections import namedtuple +from PIL import Image, ImageDraw, ImagePalette """ import asyncio import signal -import struct import subprocess """ @@ -27,12 +30,8 @@ __version__ = "0.0.0-auto.0" __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git" _displays = [] -_groups = [] - -class _DisplayioSingleton: - def __init__(self): - pass +Rectangle = namedtuple("Rectangle", "x1 y1 x2 y2") def release_displays(): @@ -75,7 +74,7 @@ class Bitmap: raise NotImplementedError("Invalid bits per value") self._data = (width * height) * [0] - self._dirty_area = {"x1": 0, "x2": width, "y1": 0, "y2": height} + self._dirty_area = Rectangle(0, 0, width, height) def __getitem__(self, index): """ @@ -83,7 +82,9 @@ class Bitmap: an x,y tuple or an int equal to `y * width + x`. """ if isinstance(index, (tuple, list)): - index = index[1] * self._width + index[0] + index = (index[1] * self._width) + index[0] + if index >= len(self._data): + raise ValueError("Index {} is out of range".format(index)) return self._data[index] def __setitem__(self, index, value): @@ -101,29 +102,29 @@ class Bitmap: x = index % self._width y = index // self._width self._data[index] = value - if self._dirty_area["x1"] == self._dirty_area["x2"]: - self._dirty_area["x1"] = x - self._dirty_area["x2"] = x + 1 - self._dirty_area["y1"] = y - self._dirty_area["y2"] = y + 1 + if self._dirty_area.x1 == self._dirty_area.x2: + self._dirty_area.x1 = x + self._dirty_area.x2 = x + 1 + self._dirty_area.y1 = y + self._dirty_area.y2 = y + 1 else: - if x < self._dirty_area["x1"]: - self._dirty_area["x1"] = x - elif x >= self._dirty_area["x2"]: - self._dirty_area["x2"] = x + 1 - if y < self._dirty_area["y1"]: - self._dirty_area["y1"] = y - elif y >= self._dirty_area["y2"]: - self._dirty_area["y2"] = y + 1 + if x < self._dirty_area.x1: + self._dirty_area.x1 = x + elif x >= self._dirty_area.x2: + self._dirty_area.x2 = x + 1 + if y < self._dirty_area.y1: + self._dirty_area.y1 = y + elif y >= self._dirty_area.y2: + self._dirty_area.y2 = y + 1 def _finish_refresh(self): - self._dirty_area["x1"] = 0 - self._dirty_area["x2"] = 0 + self._dirty_area.x1 = 0 + self._dirty_area.x2 = 0 def fill(self, value): """Fills the bitmap with the supplied palette index value.""" self._data = (self._width * self._height) * [value] - self._dirty_area = {"x1": 0, "x2": self._width, "y1": 0, "y2": self._height} + self._dirty_area = Rectangle(0, 0, self._width, self._height) @property def width(self): @@ -203,6 +204,9 @@ class ColorConverter: else: return self._compute_rgb565(color) + def _pil_palette(self): + return None + @property def dither(self): "When true the color converter dithers the output by adding random noise when truncating to display bitdepth" @@ -268,9 +272,9 @@ class Display: The initialization sequence should always leave the display memory access inline with the scan of the display to minimize tearing artifacts. """ self._bus = display_bus - self._set_column_command = 0x2A - self._set_row_command = 0x2B - self._write_ram_command = 0x2C + self._set_column_command = set_column_command + self._set_row_command = set_row_command + self._write_ram_command = write_ram_command self._brightness_command = brightness_command self._data_as_commands = data_as_commands self._single_byte_bounds = single_byte_bounds @@ -283,23 +287,24 @@ class Display: self._brightness = brightness self._auto_refresh = auto_refresh 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 _displays.append(self) + self._refresh_thread = None + if self._auto_refresh: + self.auto_refresh = True def _initialize(self, init_sequence): i = 0 while i < len(init_sequence): - command = bytes([init_sequence[i]]) + command = init_sequence[i] data_size = init_sequence[i + 1] delay = (data_size & 0x80) > 0 data_size &= ~0x80 data_byte = init_sequence[i + 2] - if self._single_byte_bounds: - data = command + init_sequence[i + 2 : i + 2 + data_size] - self._bus.send(True, data, toggle_every_byte=True) - else: - self._bus.send(True, command, toggle_every_byte=True) - if data_size > 0: - self._bus.send(False, init_sequence[i + 2 : i + 2 + data_size]) + self._write(command, init_sequence[i + 2 : i + 2 + data_size]) delay_time_ms = 10 if delay: data_size += 1 @@ -309,6 +314,13 @@ class Display: time.sleep(delay_time_ms / 1000) i += 2 + data_size + def _write(self, command, data): + if self._single_byte_bounds: + self._bus.send(True, bytes([command]) + data, toggle_every_byte=True) + else: + self._bus.send(True, bytes([command]), toggle_every_byte=True) + self._bus.send(False, data) + def _release(self): self._bus.release() self._bus = None @@ -316,7 +328,7 @@ class Display: def show(self, group): """Switches to displaying the given group of layers. When group is None, the default CircuitPython terminal will be shown. """ - pass + self._current_group = group def refresh(self, *, target_frames_per_second=60, minimum_frames_per_second=1): """When auto refresh is off, waits for the target frame rate and then refreshes the display, returning True. If the call has taken too long since the last refresh call for the given target frame rate, then the refresh returns False immediately without updating the screen to hopefully help getting caught up. @@ -325,7 +337,56 @@ class Display: When auto refresh is on, updates the display immediately. (The display will also update without calls to this.) """ - pass + # Go through groups and and add each to buffer + if self._current_group is not None: + buffer = Image.new("RGB", (self._width, self._height)) + # Recursively have everything draw to the image + self._current_group._fill_area(buffer) + # save image to buffer (or probably refresh buffer so we can compare) + self._buffer.paste(buffer) + print("refreshing") + time.sleep(1) + # Eventually calculate dirty rectangles here + self._subrectangles.append(Rectangle(0, 0, self._width, self._height)) + + for area in self._subrectangles: + self._refresh_display_area(area) + + def _refresh_loop(self): + while self._auto_refresh: + self.refresh() + + def _refresh_display_area(self, rectangle): + """Loop through dirty rectangles and redraw that area.""" + """Read or write a block of data.""" + data = numpy.array(self._buffer.crop(rectangle).convert("RGB")).astype("uint16") + color = ( + ((data[:, :, 0] & 0xF8) << 8) + | ((data[:, :, 1] & 0xFC) << 3) + | (data[:, :, 2] >> 3) + ) + + pixels = list( + numpy.dstack(((color >> 8) & 0xFF, color & 0xFF)).flatten().tolist() + ) + + self._write( + self._set_column_command, + self._encode_pos( + rectangle.x1 + self._colstart, rectangle.x2 + self._colstart + ), + ) + self._write( + self._set_row_command, + self._encode_pos( + rectangle.y1 + self._rowstart, rectangle.y2 + self._rowstart + ), + ) + self._write(self._write_ram_command, pixels) + + def _encode_pos(self, x, y): + """Encode a postion into bytes.""" + return struct.pack(self._bounds_encoding, x, y) def fill_row(self, y, buffer): pass @@ -337,6 +398,16 @@ class Display: @auto_refresh.setter def auto_refresh(self, value): self._auto_refresh = value + if self._refresh_thread is None: + self._refresh_thread = threading.Thread( + target=self._refresh_loop, daemon=True + ) + if value and not self._refresh_thread.is_alive(): + # Start the thread + self._refresh_thread.start() + elif not value and self._refresh_thread.is_alive(): + # Stop the thread + self._refresh_thread.join() @property def brightness(self): @@ -497,10 +568,10 @@ class FourWire: self._reset.value = True time.sleep(0.001) - def send(self, command, data, *, toggle_every_byte=False): + def send(self, is_command, data, *, toggle_every_byte=False): while self._spi.try_lock(): pass - self._dc.value = not command + self._dc.value = not is_command if toggle_every_byte: for byte in data: self._spi.write(bytes([byte])) @@ -581,6 +652,14 @@ class Group: """Deletes the value at the given index.""" del self._layers[index] + def _fill_area(self, buffer): + if self._hidden: + return + + for layer in self._layers: + if isinstance(layer, (Group, TileGrid)): + layer._fill_area(buffer) + @property def hidden(self): return self._hidden @@ -640,23 +719,23 @@ class I2CDisplay: pass -class OnDisplayBitmap: +class OnDiskBitmap: """ Loads values straight from disk. This minimizes memory use but can lead to much slower pixel load times. These load times may result in frame tearing where only part of the image is visible.""" def __init__(self, file): - pass + self._image = Image.open(file) @property def width(self): """Width of the bitmap. (read only)""" - pass + return self._image.width @property def height(self): """Height of the bitmap. (read only)""" - pass + return self._image.height class Palette: @@ -665,6 +744,7 @@ class Palette: def __init__(self, color_count): """Create a Palette object to store a set number of colors.""" self._needs_refresh = False + self._colors = [] for _ in range(color_count): self._colors.append(self._make_color(0)) @@ -673,10 +753,6 @@ class Palette: color = { "transparent": False, "rgb888": 0, - "rgb565": 0, - "luma": 0, - "chroma": 0, - "hue": 0, } color_converter = ColorConverter() if isinstance(value, (tuple, list, bytes, bytearray)): @@ -687,10 +763,6 @@ class Palette: else: raise TypeError("Color buffer must be a buffer, tuple, list, or int") color["rgb888"] = value - color["rgb565"] = color_converter._compute_rgb565(value) - color["chroma"] = color_converter._compute_chroma(value) - color["luma"] = color_converter._compute_luma(value) - color["hue"] = color_converter._compute_hue(value) self._needs_refresh = True return color @@ -708,13 +780,15 @@ class Palette: self._colors[index] = self._make_color(value) def __getitem__(self, index): - pass + if not 0 <= index < len(self._colors): + raise ValueError("Palette index out of range") + return self._colors[index] def make_transparent(self, palette_index): - self._colors[palette_index].transparent = True + self._colors[palette_index]["transparent"] = True def make_opaque(self, palette_index): - self._colors[palette_index].transparent = False + self._colors[palette_index]["transparent"] = False class ParallelBus: @@ -742,7 +816,7 @@ class ParallelBus: pass -class Shape: +class Shape(Bitmap): """Create a Shape object with the given fixed size. Each pixel is one bit and is stored by the column boundaries of the shape on each row. Each row’s boundary defaults to the full row. """ @@ -781,28 +855,85 @@ class TileGrid: tile_width and tile_height match the height of the bitmap by default. """ + if not isinstance(bitmap, (Bitmap, OnDiskBitmap, Shape)): + raise ValueError("Unsupported Bitmap type") self._bitmap = bitmap + bitmap_width = bitmap.width + bitmap_height = bitmap.height + + if not isinstance(pixel_shader, (ColorConverter, Palette)): + raise ValueError("Unsupported Pixel Shader type") self._pixel_shader = pixel_shader - self_hidden = False + self._hidden = False self._x = x self._y = y - self._width = width - self._height = height + self._width = width # Number of Tiles Wide + self._height = height # Number of Tiles High if tile_width is None: - tile_width = width + tile_width = bitmap_width if tile_height is None: - tile_height = height + tile_height = bitmap_height + if bitmap_width % tile_width != 0: + raise ValueError("Tile width must exactly divide bitmap width") self._tile_width = tile_width + if bitmap_height % tile_height != 0: + raise ValueError("Tile height must exactly divide bitmap height") self._tile_height = tile_height + if not 0 <= default_tile <= 255: + raise ValueError("Default Tile is out of range") + self._tiles = (self._width * self._height) * [default_tile] + + def _fill_area(self, buffer): + """Draw onto the image""" + if self._hidden: + return + + image = Image.new( + "RGB", (self._width * self._tile_width, self._height * self._tile_height) + ) + + tile_count_x = self._bitmap.width // self._tile_width + tile_count_y = self._bitmap.height // self._tile_height + + for tile_x in range(0, self._width): + for tile_y in range(0, self._height): + tile_index = self._tiles[tile_y * self._width + tile_x] + tile_index_x = tile_index % tile_count_x + tile_index_y = tile_index // tile_count_x + for pixel_x in range(self._tile_width): + for pixel_y in range(self._tile_height): + image_x = tile_x * self._tile_width + pixel_x + image_y = tile_y * self._tile_height + pixel_y + bitmap_x = tile_index_x * self._tile_width + pixel_x + bitmap_y = tile_index_y * self._tile_height + pixel_y + pixel_color = self._pixel_shader[ + self._bitmap[bitmap_x, bitmap_y] + ] + if not pixel_color["transparent"]: + image.putpixel((image_x, image_y), pixel_color["rgb888"]) + + # Apply transforms here + if self._tile_width == 6: + print("Putting at {}".format((self._x, self._y))) + buffer.paste(image, (self._x, self._y)) + """ + Strategy + ------------ + Draw on it + Do any transforms or mirrors or whatever + Paste into buffer at our x,y position + """ @property def hidden(self): """True when the TileGrid is hidden. This may be False even when a part of a hidden Group.""" - return self_hidden + return self._hidden @hidden.setter def hidden(self, value): - self._hidden = value + if not isinstance(value, (bool, int)): + raise ValueError("Expecting a boolean or integer value") + self._hidden = bool(value) @property def x(self): @@ -859,15 +990,20 @@ class TileGrid: an x,y tuple or an int equal to ``y * width + x``'. """ if isinstance(index, (tuple, list)): - index = index[1] * self._width + index[0] - return self._data[index] + x = index[0] + y = index[1] + index = y * self._width + x + elif ininstance(index, int): + x = index % self._width + y = index // self._width + if x > self._width or y > self._height: + raise ValueError("Tile index out of bounds") + return self._tiles[index] def __setitem__(self, index, value): """Sets the tile index at the given index. The index can either be an x,y tuple or an int equal to ``y * width + x``. """ - if self._read_only: - raise RuntimeError("Read-only object") if isinstance(index, (tuple, list)): x = index[0] y = index[1] @@ -875,4 +1011,8 @@ class TileGrid: elif ininstance(index, int): x = index % self._width y = index // self._width - self._data[index] = value + if x > width or y > self._height or index >= len(self._tiles): + raise ValueError("Tile index out of bounds") + if not 0 <= value <= 255: + raise ValueError("Tile value out of bounds") + self._tiles[index] = value