During the Covid pandemic, I built some games for my children, using Neopixel panels, a joystick module and Arduino. Around the same time, I learned how to use Adafruit's Circuitpython and slowly started to port some of my projects to this platform, especially due to its practicality and speed of implementation.
This year I'm offering some workshops at Sesc São Paulo and I would really like to set up a screen for Arcade games using these panels, but this time using Circuitpython.
Is this possible?
The first big surprise was discovering the Maker Melissa amazing Circuitpython_Pixel_Framebuf library, which already does a lot of work. Draw rectangles, circles, lines and even write text. At the end of the tutorial there is even the possibility of using images, unfortunately only using a Linux computer and the Pillow library...
But wait a minute... If it is possible to use images on a computer, what is missing to use with the microcontroller?
I know that the Pillow library treats images and performs a series of conversions in a very simple way. But what exact image format would be needed to do this on a microcontroller, with little memory, low speed, etc.? Could I generate sprites? Would the best format be bitmap?
Let's go through steps...
No. I'm not going to teach you how to install Circuitpython...But you can look it up here: https://learn.adafruit.com/welcome-to-circuitpython/installing-circuitpython
And, also, I need you to install certain libraries, and the way I like to do this the most is using circup.
But to use Circup, you will need to have Python 3 installed, with Pip configured and everything else...
Once you do all this, it will be easy to install the necessary libraries. Just connect your board, open your terminal and type:
circup install neopixel adafruit_framebuf adafruit_pixelbuf adafruit_led_animation
Now, jump to Maker Melissa tutorialYeah. I'm a little lazy today.
But seriously. The best place to start is always the tutorials at Adafruit. We're going to have a problem with the Pixel_Framebuf library, but I want you to get there little by little.
The tutorial is quite complete and even covers problems with power supplies, which should always be observed when working with LED strips.
I'm using the Seeed Xiao RP2040 in my project, because of its size. So maybe your pinouts are a little different.
Using the panels in many different ways...If you are going to use the panels side by side horizontally, composing large strips of 8x64, 8x128, etc... the library will do the job and you won't need my solution.
The thing is that I need the panels organized differently, in a combination of 16x32 or even 32x32. And then I couldn't get the coordinates to be correct.
My first step was to look for a solution through trial and error, changing the default settings to understand if there was a problem with the startup. Then I searched the Adafruit forums and finally decided to take a look at the library's source code, to understand how it makes the magic happen.
The source code is available on Github: https://github.com/adafruit/Adafruit_CircuitPython_Pixel_Framebuf/blob/main/adafruit_pixel_framebuf.py
I usually take a look at the beginning of the code, at all the developer comments. And then, I look for the imported libraries to understand what is being done.
In this case, I saw that there was an exclusive library to deal with the framebuffer (adafruit_framebuf) and another that seemed to deal with a pixel grid (module PixelGrid, from adafruit_led_animation library). As my problem was with the panel coordinates, I understood that I should study this library first.
The source code is also on Github: https://github.com/djairjr/Adafruit_CircuitPython_LED_Animation/blob/main/adafruit_led_animation/grid.py
Using the same procedure as above, I realized that this second library imported some helper modules called PixelMap, horizontal_strip_gridmap and vertical_strip_gridmap.
These LED panels come in many different configurations and sometimes the pixels are arranged in a zig zag format. These auxiliary modules help to handle each case and the solution presented is quite elegant.
In the even lines, we have increasing coordinates until the maximum height number arrives. In the odd rows, we start with the multiple of the height and decrease.
With the function below, I was able to reach the coordinate configuration I needed. The way this is done in the original library is much more sophisticated, but I don't have enough knowledge to do it the same way. But this solution can handle many different arrangements for this type of panel that I am working with.
To use the function, to generate any grid, you must enter the width and height parameters (from the simple panel) and then the tiles you want.
The biggest problem with my solution is that it needs to generate the complete grid as a list of tuples and this is stored in memory. Maybe it's complicated with larger grids, but it's enough to solve my problem.
As long as you respect the horizontal alignment of the panel, the mapping will work. With this, you can use my function to create 16x64 arrangements, for example.
def generate_grid(width, height, tile_num):
def single_tile_coord():
single_tile = []
for i in range(width * height):
group_index = i // height
ascending = (group_index % 2) == 0
number_within_group = i % height
if ascending:
number = group_index * height + number_within_group
else:
number = (group_index + 1) * height - 1 - number_within_group
if number_within_group == 0:
temp = []
temp.append (number)
if number_within_group == height - 1:
single_tile.append(tuple(temp))
return single_tile
def multi_tile(original_matrix):
multi_temp = []
factor = tile_num - 1
if factor == 0:
multi_temp = original_matrix
else:
for tup in original_matrix:
modified_tuple = tup[:height]
for n in range (factor):
modified_tuple += tuple(num + (factor * (width * height)) for num in tup[:height])
multi_temp.append(modified_tuple)
return multi_temp
default_tile = single_tile_coord()
return multi_tile(default_tile)
So I created two modified versions of the original libraries, which I called tilegrid and tile_framebuf. And I called this new function in order to organize the panel.
"""
My Tile Grid version
"""
from micropython import const
# This change is because I am using the library outside of his correct folder.
# So I need to load the original adafruit_led_animation.Helper module.
#from .helper import PixelMap
from adafruit_led_animation.Helper import PixelMap
HORIZONTAL = const(1)
VERTICAL = const(2)
class TileGrid:
"""
TileGrid lets you address a vertical tiled pixel panel with x and y coordinates.
:param strip: An object that implements the Neopixel or Dotstar protocol.
:param width: Grid width.
:param height: Grid height.
:param tile_num: Number of Tiles.
:param orientation: Orientation of the strip pixels - HORIZONTAL (default) or VERTICAL.
I still don't test this configurations.
:param alternating: Whether the strip alternates direction from row to row (default True).
:param reverse_x: Whether the strip X origin is on the right side (default False).
:param reverse_y: Whether the strip Y origin is on the bottom (default False).
:param tuple top: (x, y) coordinates of grid top left corner (Optional)
:param tuple bottom: (x, y) coordinates of grid bottom right corner (Optional)
"""
def __init__(
self,
strip,
width, # of individual tile
height, # of individual tile
tile_num, # number of tiles
orientation=HORIZONTAL,
alternating=True,
reverse_x=False,
reverse_y=False,
top=0,
bottom=0,
): # pylint: disable=too-many-arguments,too-many-locals
self._pixels = strip
self._x = []
self.height = height
self.width = width
self.tile_num = tile_num
if self.tile_num > 1: # Check if you have more than one tile
# And if you have, call that special function...
mapper = generate_grid(self.width, self.height, self.tile_num)
for m in mapper:
self._x.append(
PixelMap(
strip,
m,
individual_pixels=True,
)
)
self.n = len(self._x)
def __repr__(self):
return "[" + ", ".join([str(self[x]) for x in range(self.n)]) + "]"
def __setitem__(self, index, val):
if isinstance(index, slice):
raise NotImplementedError("PixelGrid does not support slices")
if isinstance(index, tuple):
self._x[index[0]][index[1]] = val
else:
raise ValueError("PixelGrid assignment needs a sub-index or x,y coordinate")
if self._pixels.auto_write:
self.show()
def __getitem__(self, index):
if isinstance(index, slice):
raise NotImplementedError("PixelGrid does not support slices")
if index < 0:
index += len(self)
if index >= self.n or index < 0:
raise IndexError("x is out of range")
return self._x[index]
def __len__(self):
return self.n
@property
def brightness(self):
"""
brightness from the underlying strip.
"""
return self._pixels.brightness
@brightness.setter
def brightness(self, brightness):
# pylint: disable=attribute-defined-outside-init
self._pixels.brightness = min(max(brightness, 0.0), 1.0)
def fill(self, color):
"""
Fill the PixelGrid with the specified color.
:param color: Color to use.
"""
for strip in self._x:
strip.fill(color)
def show(self):
"""
Shows the pixels on the underlying strip.
"""
self._pixels.show()
@property
def auto_write(self):
"""
auto_write from the underlying strip.
"""
return self._pixels.auto_write
@auto_write.setter
def auto_write(self, value):
self._pixels.auto_write = value
def reverse_x_mapper(width, mapper):
"""
Returns a coordinate mapper function for grids with reversed X coordinates.
I think that is the best approach. The function, once is called, generate only
the needed coordinate.
:param width: width of strip
:param mapper: grid mapper to wrap
:return: mapper(x, y)
"""
max_x = width - 1
def x_mapper(x, y):
return mapper(max_x - x, y)
return x_mapper
def reverse_y_mapper(height, mapper):
"""
Returns a coordinate mapper function for grids with reversed Y coordinates.
I think that is the best approach. The function, once is called, generate only
the needed coordinate.
:param height: width of strip
:param mapper: grid mapper to wrap
:return: mapper(x, y)
"""
max_y = height - 1
def y_mapper(x, y):
return mapper(x, max_y - y)
return y_mapper
# My function comes here...
def generate_grid(width, height, tile_num):
def single_tile_coord():
single_tile = []
for i in range(width * height):
group_index = i // height
ascending = (group_index % 2) == 0
number_within_group = i % height
if ascending:
number = group_index * height + number_within_group
else:
number = (group_index + 1) * height - 1 - number_within_group
if number_within_group == 0:
temp = []
temp.append (number)
if number_within_group == height - 1:
single_tile.append(tuple(temp))
return single_tile
def multi_tile(original_matrix):
multi_temp = []
factor = tile_num - 1
if factor == 0:
multi_temp = original_matrix
else:
for tup in original_matrix:
modified_tuple = tup[:height]
for n in range (factor):
modified_tuple += tuple(num + (factor * (width * height)) for num in tup[:height])
multi_temp.append(modified_tuple)
return multi_temp
default_tile = single_tile_coord()
return multi_tile(default_tile)
Also my tile_framebuf version:
"""
My Tile_framebuf
"""
# imports
try:
from circuitpython_typing.led import FillBasedColorUnion
except ImportError:
pass
import adafruit_framebuf
# This is the original import.
# from adafruit_led_animation.grid import PixelGrid
# But i am using my custom tilegrid module:
from tilegrid import TileGrid
from micropython import const
HORIZONTAL: int = const(1)
VERTICAL: int = const(2)
# pylint: disable=too-many-function-args
class TileFramebuffer(adafruit_framebuf.FrameBuffer):
"""
NeoPixel and Dotstar FrameBuffer for easy drawing and text on a
tile grid of either kind of pixel
:param strip: An object that implements the Neopixel or Dotstar protocol.
:param width: Framebuffer width.
:param height: Framebuffer height.
:param tile_num: Number of identical tiles
:param int rotation: A value of 0-3 representing the rotation of the framebuffer (default 0)
"""
# pylint: disable=too-many-arguments
def __init__(
self,
pixels: FillBasedColorUnion,
width: int,
height: int,
tile_num: int,
# Cant use this orientation stuff yet
orientation: int = HORIZONTAL,
alternating: bool = True,
reverse_x: bool = False,
reverse_y: bool = False,
top: int = 0,
bottom: int = 0,
rotation: int = 0,
) -> None:
self._width = width
self._height = height
self._tile_num = tile_num
# My Custom Class
self._grid = TileGrid(
pixels,
width,
height,
tile_num,
)
# Need to multiply buffer for tile_num
self._buffer = bytearray(width * height * tile_num * 3)
self._double_buffer = bytearray(width * height * tile_num * 3)
super().__init__(
self._buffer, width, height * tile_num, buf_format=adafruit_framebuf.RGB888
)
self.rotation = rotation
def blit(self) -> None:
"""blit is not yet implemented"""
raise NotImplementedError()
def display(self) -> None:
"""Copy the raw buffer changes to the grid and show"""
# don't forget to multiply for tile_num
for _y in range(self._height * self._tile_num):
for _x in range(self._width):
index = (_y * self.stride + _x) * 3
if (
self._buffer[index : index + 3]
!= self._double_buffer[index : index + 3]
):
self._grid[(_x, _y)] = tuple(self._buffer[index : index + 3])
self._double_buffer[index : index + 3] = self._buffer[
index : index + 3
]
self._grid.show()
Save both files in your library folder (/lib) with the names tilegrid.py and tile_framebuf.py
Also need to make some changes into adafruit_framebuf library, in order to read a small RGB Bitmap, directly from Circuitpython (without Pillow). Good for my Sprites!
# SPDX-FileCopyrightText: <text> 2018 Kattni Rembor, Melissa LeBlanc-Williams
# and Tony DiCola, for Adafruit Industries.
# Original file created by Damien P. George </text>
#
# SPDX-License-Identifier: MIT
"""
`changed_adafruit_framebuf`
====================================================
I've created BMP Reader Class, based on https://github.com/stuartm2/CircuitPython_BMP_Reader work.
And create a method to read directly an BMP file from file system.
"""
__version__ = "0.0.0+auto.0"
__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_framebuf.git"
import os
import struct
# Framebuf format constants:
MVLSB = 0 # Single bit displays (like SSD1306 OLED)
RGB565 = 1 # 16-bit color displays
GS4_HMSB = 2 # Unimplemented!
MHMSB = 3 # Single bit displays like the Sharp Memory
RGB888 = 4 # Neopixels and Dotstars
GS2_HMSB = 5 # 2-bit color displays like the HT16K33 8x8 Matrix
# Adding BMP Reader https://github.com/stuartm2/CircuitPython_BMP_Reader
class BMPReader(object):
def __init__(self, filename):
self._filename = filename
self._read_img_data()
def get_pixels(self):
pixel_grid = []
pixel_data = list(self._pixel_data) # Working with a copy
bytes_per_pixel = 3 #24-bit color depth (RGB)
row_size = self.width * bytes_per_pixel
for y in range(self.height):
row = []
for x in range(self.width):
idx = (y * row_size) + (x * bytes_per_pixel)
b = pixel_data[idx]
g = pixel_data[idx + 1]
r = pixel_data[idx + 2]
row.append((r, g, b))
pixel_grid.append(row)
return pixel_grid
def _read_img_data(self):
def lebytes_to_int(bytes):
n = 0x00
for b in reversed(bytes):
n = (n << 8) | b
return n
with open(self._filename, 'rb') as f:
img_bytes = bytearray(f.read())
# Check BMP Format
assert img_bytes[0:2] == b'BM', "Not a valid BMP File"
assert lebytes_to_int(img_bytes[30:34]) == 0, \
"A compressão não é suportada"
assert lebytes_to_int(img_bytes[28:30]) == 24, \
"Only RGB 24 Bits supported"
# Get Image Dimensions
start_pos = lebytes_to_int(img_bytes[10:14])
self.width = lebytes_to_int(img_bytes[18:22])
self.height = lebytes_to_int(img_bytes[22:26])
# Get Pixel Data
self._pixel_data = img_bytes[start_pos:]
class GS2HMSBFormat:
"""GS2HMSBFormat"""
@staticmethod
def set_pixel(framebuf, x, y, color):
"""Set a given pixel to a color."""
index = (y * framebuf.stride + x) >> 2
pixel = framebuf.buf[index]
shift = (x & 0b11) << 1
mask = 0b11 << shift
color = (color & 0b11) << shift
framebuf.buf[index] = color | (pixel & (~mask))
@staticmethod
def get_pixel(framebuf, x, y):
"""Get the color of a given pixel"""
index = (y * framebuf.stride + x) >> 2
pixel = framebuf.buf[index]
shift = (x & 0b11) << 1
return (pixel >> shift) & 0b11
@staticmethod
def fill(framebuf, color):
"""completely fill/clear the buffer with a color"""
if color:
bits = color & 0b11
fill = (bits << 6) | (bits << 4) | (bits << 2) | (bits << 0)
else:
fill = 0x00
framebuf.buf = [fill for i in range(len(framebuf.buf))]
@staticmethod
def rect(framebuf, x, y, width, height, color):
"""Draw the outline of a rectangle at the given location, size and color."""
# pylint: disable=too-many-arguments
for _x in range(x, x + width):
for _y in range(y, y + height):
if _x in [x, x + width] or _y in [y, y + height]:
GS2HMSBFormat.set_pixel(framebuf, _x, _y, color)
@staticmethod
def fill_rect(framebuf, x, y, width, height, color):
"""Draw the outline and interior of a rectangle at the given location, size and color."""
# pylint: disable=too-many-arguments
for _x in range(x, x + width):
for _y in range(y, y + height):
GS2HMSBFormat.set_pixel(framebuf, _x, _y, color)
class MHMSBFormat:
"""MHMSBFormat"""
@staticmethod
def set_pixel(framebuf, x, y, color):
"""Set a given pixel to a color."""
index = (y * framebuf.stride + x) // 8
offset = 7 - x & 0x07
framebuf.buf[index] = (framebuf.buf[index] & ~(0x01 << offset)) | (
(color != 0) << offset
)
@staticmethod
def get_pixel(framebuf, x, y):
"""Get the color of a given pixel"""
index = (y * framebuf.stride + x) // 8
offset = 7 - x & 0x07
return (framebuf.buf[index] >> offset) & 0x01
@staticmethod
def fill(framebuf, color):
"""completely fill/clear the buffer with a color"""
if color:
fill = 0xFF
else:
fill = 0x00
for i in range(len(framebuf.buf)): # pylint: disable=consider-using-enumerate
framebuf.buf[i] = fill
@staticmethod
def fill_rect(framebuf, x, y, width, height, color):
"""Draw a rectangle at the given location, size and color. The ``fill_rect`` method draws
both the outline and interior."""
# pylint: disable=too-many-arguments
for _x in range(x, x + width):
offset = 7 - _x & 0x07
for _y in range(y, y + height):
index = (_y * framebuf.stride + _x) // 8
framebuf.buf[index] = (framebuf.buf[index] & ~(0x01 << offset)) | (
(color != 0) << offset
)
class MVLSBFormat:
"""MVLSBFormat"""
@staticmethod
def set_pixel(framebuf, x, y, color):
"""Set a given pixel to a color."""
index = (y >> 3) * framebuf.stride + x
offset = y & 0x07
framebuf.buf[index] = (framebuf.buf[index] & ~(0x01 << offset)) | (
(color != 0) << offset
)
@staticmethod
def get_pixel(framebuf, x, y):
"""Get the color of a given pixel"""
index = (y >> 3) * framebuf.stride + x
offset = y & 0x07
return (framebuf.buf[index] >> offset) & 0x01
@staticmethod
def fill(framebuf, color):
"""completely fill/clear the buffer with a color"""
if color:
fill = 0xFF
else:
fill = 0x00
for i in range(len(framebuf.buf)): # pylint: disable=consider-using-enumerate
framebuf.buf[i] = fill
@staticmethod
def fill_rect(framebuf, x, y, width, height, color):
"""Draw a rectangle at the given location, size and color. The ``fill_rect`` method draws
both the outline and interior."""
# pylint: disable=too-many-arguments
while height > 0:
index = (y >> 3) * framebuf.stride + x
offset = y & 0x07
for w_w in range(width):
framebuf.buf[index + w_w] = (
framebuf.buf[index + w_w] & ~(0x01 << offset)
) | ((color != 0) << offset)
y += 1
height -= 1
class RGB565Format:
"""
This class implements the RGB565 format
It assumes a little-endian byte order in the frame buffer
"""
@staticmethod
def color_to_rgb565(color):
"""Convert a color in either tuple or 24 bit integer form to RGB565,
and return as two bytes"""
if isinstance(color, tuple):
hibyte = (color[0] & 0xF8) | (color[1] >> 5)
lobyte = ((color[1] << 5) & 0xE0) | (color[2] >> 3)
else:
hibyte = ((color >> 16) & 0xF8) | ((color >> 13) & 0x07)
lobyte = ((color >> 5) & 0xE0) | ((color >> 3) & 0x1F)
return bytes([lobyte, hibyte])
def set_pixel(self, framebuf, x, y, color):
"""Set a given pixel to a color."""
index = (y * framebuf.stride + x) * 2
framebuf.buf[index : index + 2] = self.color_to_rgb565(color)
@staticmethod
def get_pixel(framebuf, x, y):
"""Get the color of a given pixel"""
index = (y * framebuf.stride + x) * 2
lobyte, hibyte = framebuf.buf[index : index + 2]
r = hibyte & 0xF8
g = ((hibyte & 0x07) << 5) | ((lobyte & 0xE0) >> 5)
b = (lobyte & 0x1F) << 3
return (r << 16) | (g << 8) | b
def fill(self, framebuf, color):
"""completely fill/clear the buffer with a color"""
rgb565_color = self.color_to_rgb565(color)
for i in range(0, len(framebuf.buf), 2):
framebuf.buf[i : i + 2] = rgb565_color
def fill_rect(self, framebuf, x, y, width, height, color):
"""Draw a rectangle at the given location, size and color. The ``fill_rect`` method draws
both the outline and interior."""
# pylint: disable=too-many-arguments
rgb565_color = self.color_to_rgb565(color)
for _y in range(2 * y, 2 * (y + height), 2):
offset2 = _y * framebuf.stride
for _x in range(2 * x, 2 * (x + width), 2):
index = offset2 + _x
framebuf.buf[index : index + 2] = rgb565_color
class RGB888Format:
"""RGB888Format"""
@staticmethod
def set_pixel(framebuf, x, y, color):
"""Set a given pixel to a color."""
index = (y * framebuf.stride + x) * 3
if isinstance(color, tuple):
framebuf.buf[index : index + 3] = bytes(color)
else:
framebuf.buf[index : index + 3] = bytes(
((color >> 16) & 255, (color >> 8) & 255, color & 255)
)
@staticmethod
def get_pixel(framebuf, x, y):
"""Get the color of a given pixel"""
index = (y * framebuf.stride + x) * 3
return (
(framebuf.buf[index] << 16)
| (framebuf.buf[index + 1] << 8)
| framebuf.buf[index + 2]
)
@staticmethod
def fill(framebuf, color):
"""completely fill/clear the buffer with a color"""
fill = (color >> 16) & 255, (color >> 8) & 255, color & 255
for i in range(0, len(framebuf.buf), 3):
framebuf.buf[i : i + 3] = bytes(fill)
@staticmethod
def fill_rect(framebuf, x, y, width, height, color):
"""Draw a rectangle at the given location, size and color. The ``fill_rect`` method draws
both the outline and interior."""
# pylint: disable=too-many-arguments
fill = (color >> 16) & 255, (color >> 8) & 255, color & 255
for _x in range(x, x + width):
for _y in range(y, y + height):
index = (_y * framebuf.stride + _x) * 3
framebuf.buf[index : index + 3] = bytes(fill)
class FrameBuffer:
"""FrameBuffer object.
:param buf: An object with a buffer protocol which must be large enough to contain every
pixel defined by the width, height and format of the FrameBuffer.
:param width: The width of the FrameBuffer in pixel
:param height: The height of the FrameBuffer in pixel
:param buf_format: Specifies the type of pixel used in the FrameBuffer; permissible values
are listed under Constants below. These set the number of bits used to
encode a color value and the layout of these bits in ``buf``. Where a
color value c is passed to a method, c is a small integer with an encoding
that is dependent on the format of the FrameBuffer.
:param stride: The number of pixels between each horizontal line of pixels in the
FrameBuffer. This defaults to ``width`` but may need adjustments when
implementing a FrameBuffer within another larger FrameBuffer or screen. The
``buf`` size must accommodate an increased step size.
"""
def __init__(self, buf, width, height, buf_format=MVLSB, stride=None):
# pylint: disable=too-many-arguments
self.buf = buf
self.width = width
self.height = height
self.stride = stride
self._font = None
if self.stride is None:
self.stride = width
if buf_format == MVLSB:
self.format = MVLSBFormat()
elif buf_format == MHMSB:
self.format = MHMSBFormat()
elif buf_format == RGB888:
self.format = RGB888Format()
elif buf_format == RGB565:
self.format = RGB565Format()
elif buf_format == GS2_HMSB:
self.format = GS2HMSBFormat()
else:
raise ValueError("invalid format")
self._rotation = 0
@property
def rotation(self):
"""The rotation setting of the display, can be one of (0, 1, 2, 3)"""
return self._rotation
@rotation.setter
def rotation(self, val):
if not val in (0, 1, 2, 3):
raise RuntimeError("Bad rotation setting")
self._rotation = val
def fill(self, color):
"""Fill the entire FrameBuffer with the specified color."""
self.format.fill(self, color)
def fill_rect(self, x, y, width, height, color):
"""Draw a rectangle at the given location, size and color. The ``fill_rect`` method draws
both the outline and interior."""
# pylint: disable=too-many-arguments, too-many-boolean-expressions
self.rect(x, y, width, height, color, fill=True)
def pixel(self, x, y, color=None):
"""If ``color`` is not given, get the color value of the specified pixel. If ``color`` is
given, set the specified pixel to the given color."""
if self.rotation == 1:
x, y = y, x
x = self.width - x - 1
if self.rotation == 2:
x = self.width - x - 1
y = self.height - y - 1
if self.rotation == 3:
x, y = y, x
y = self.height - y - 1
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return None
if color is None:
return self.format.get_pixel(self, x, y)
self.format.set_pixel(self, x, y, color)
return None
def hline(self, x, y, width, color):
"""Draw a horizontal line up to a given length."""
self.rect(x, y, width, 1, color, fill=True)
def vline(self, x, y, height, color):
"""Draw a vertical line up to a given length."""
self.rect(x, y, 1, height, color, fill=True)
def circle(self, center_x, center_y, radius, color):
"""Draw a circle at the given midpoint location, radius and color.
The ```circle``` method draws only a 1 pixel outline."""
x = radius - 1
y = 0
d_x = 1
d_y = 1
err = d_x - (radius << 1)
while x >= y:
self.pixel(center_x + x, center_y + y, color)
self.pixel(center_x + y, center_y + x, color)
self.pixel(center_x - y, center_y + x, color)
self.pixel(center_x - x, center_y + y, color)
self.pixel(center_x - x, center_y - y, color)
self.pixel(center_x - y, center_y - x, color)
self.pixel(center_x + y, center_y - x, color)
self.pixel(center_x + x, center_y - y, color)
if err <= 0:
y += 1
err += d_y
d_y += 2
if err > 0:
x -= 1
d_x += 2
err += d_x - (radius << 1)
def rect(self, x, y, width, height, color, *, fill=False):
"""Draw a rectangle at the given location, size and color. The ```rect``` method draws only
a 1 pixel outline."""
# pylint: disable=too-many-arguments
if self.rotation == 1:
x, y = y, x
width, height = height, width
x = self.width - x - width
if self.rotation == 2:
x = self.width - x - width
y = self.height - y - height
if self.rotation == 3:
x, y = y, x
width, height = height, width
y = self.height - y - height
# pylint: disable=too-many-boolean-expressions
if (
width < 1
or height < 1
or (x + width) <= 0
or (y + height) <= 0
or y >= self.height
or x >= self.width
):
return
x_end = min(self.width - 1, x + width - 1)
y_end = min(self.height - 1, y + height - 1)
x = max(x, 0)
y = max(y, 0)
if fill:
self.format.fill_rect(self, x, y, x_end - x + 1, y_end - y + 1, color)
else:
self.format.fill_rect(self, x, y, x_end - x + 1, 1, color)
self.format.fill_rect(self, x, y, 1, y_end - y + 1, color)
self.format.fill_rect(self, x, y_end, x_end - x + 1, 1, color)
self.format.fill_rect(self, x_end, y, 1, y_end - y + 1, color)
def line(self, x_0, y_0, x_1, y_1, color):
# pylint: disable=too-many-arguments
"""Bresenham's line algorithm"""
d_x = abs(x_1 - x_0)
d_y = abs(y_1 - y_0)
x, y = x_0, y_0
s_x = -1 if x_0 > x_1 else 1
s_y = -1 if y_0 > y_1 else 1
if d_x > d_y:
err = d_x / 2.0
while x != x_1:
self.pixel(x, y, color)
err -= d_y
if err < 0:
y += s_y
err += d_x
x += s_x
else:
err = d_y / 2.0
while y != y_1:
self.pixel(x, y, color)
err -= d_x
if err < 0:
x += s_x
err += d_y
y += s_y
self.pixel(x, y, color)
def blit(self):
"""blit is not yet implemented"""
raise NotImplementedError()
def scroll(self, delta_x, delta_y):
"""shifts framebuf in x and y direction"""
if delta_x < 0:
shift_x = 0
xend = self.width + delta_x
dt_x = 1
else:
shift_x = self.width - 1
xend = delta_x - 1
dt_x = -1
if delta_y < 0:
y = 0
yend = self.height + delta_y
dt_y = 1
else:
y = self.height - 1
yend = delta_y - 1
dt_y = -1
while y != yend:
x = shift_x
while x != xend:
self.format.set_pixel(
self, x, y, self.format.get_pixel(self, x - delta_x, y - delta_y)
)
x += dt_x
y += dt_y
# pylint: disable=too-many-arguments
def text(self, string, x, y, color, *, font_name="font5x8.bin", size=1):
"""Place text on the screen in variables sizes. Breaks on \n to next line.
Does not break on line going off screen.
"""
# determine our effective width/height, taking rotation into account
frame_width = self.width
frame_height = self.height
if self.rotation in (1, 3):
frame_width, frame_height = frame_height, frame_width
for chunk in string.split("\n"):
if not self._font or self._font.font_name != font_name:
# load the font!
self._font = BitmapFont(font_name)
width = self._font.font_width
height = self._font.font_height
for i, char in enumerate(chunk):
char_x = x + (i * (width + 1)) * size
if (
char_x + (width * size) > 0
and char_x < frame_width
and y + (height * size) > 0
and y < frame_height
):
self._font.draw_char(char, char_x, y, self, color, size=size)
y += height * size
# Different Method No Pillow Needed (Good for Neopixels)
def simple_image(self, bmp_filename):
# Determine effective width/height, considering rotation
width = self.width
height = self.height
if self.rotation in (1, 3):
width, height = height, width
# Load BMP file using BMPReader
bmp_reader = BMPReader(bmp_filename)
pixels = bmp_reader.get_pixels()
# Check image dimensions
if len(pixels) != height or len(pixels[0]) != width:
raise ValueError(f"Image must be {width}x{height} pixels.")
# Clear buffer
self.fill(0) # Assuming '0' represents black
# Iterate through the pixels
for y in range(height):
for x in range(width):
# Extract RGB values from BMPReader pixel data
r, g, b = pixels[y][x]
# Set pixel on display buffer
self.pixel(x, y, (r, g, b)) # Set RGB color directly
# Optionally handle display update here
# self.update_display() # Update display after setting all pixels
# pylint: enable=too-many-arguments
def pillow_image(self, img):
"""Set buffer to value of Python Imaging Library image. The image should
be in 1 bit mode and a size equal to the display size."""
# determine our effective width/height, taking rotation into account
width = self.width
height = self.height
if self.rotation in (1, 3):
width, height = height, width
if isinstance(self.format, (RGB565Format, RGB888Format)) and img.mode != "RGB":
raise ValueError("Image must be in mode RGB.")
if isinstance(self.format, (MHMSBFormat, MVLSBFormat)) and img.mode != "1":
raise ValueError("Image must be in mode 1.")
imwidth, imheight = img.size
if imwidth != width or imheight != height:
raise ValueError(
f"Image must be same dimensions as display ({width}x{height})."
)
# Grab all the pixels from the image, faster than getpixel.
pixels = img.load()
# Clear buffer
for i in range(len(self.buf)): # pylint: disable=consider-using-enumerate
self.buf[i] = 0
# Iterate through the pixels
for x in range(width): # yes this double loop is slow,
for y in range(height): # but these displays are small!
if img.mode == "RGB":
self.pixel(x, y, pixels[(x, y)])
elif pixels[(x, y)]:
self.pixel(x, y, 1) # only write if pixel is true
# MicroPython basic bitmap font renderer.
# Author: Tony DiCola
# License: MIT License (https://opensource.org/licenses/MIT)
class BitmapFont:
"""A helper class to read binary font tiles and 'seek' through them as a
file to display in a framebuffer. We use file access so we dont waste 1KB
of RAM on a font!"""
def __init__(self, font_name="font5x8.bin"):
# Specify the drawing area width and height, and the pixel function to
# call when drawing pixels (should take an x and y param at least).
# Optionally specify font_name to override the font file to use (default
# is font5x8.bin). The font format is a binary file with the following
# format:
# - 1 unsigned byte: font character width in pixels
# - 1 unsigned byte: font character height in pixels
# - x bytes: font data, in ASCII order covering all 255 characters.
# Each character should have a byte for each pixel column of
# data (i.e. a 5x8 font has 5 bytes per character).
self.font_name = font_name
# Open the font file and grab the character width and height values.
# Note that only fonts up to 8 pixels tall are currently supported.
try:
self._font = open( # pylint: disable=consider-using-with
self.font_name, "rb"
)
self.font_width, self.font_height = struct.unpack("BB", self._font.read(2))
# simple font file validation check based on expected file size
if 2 + 256 * self.font_width != os.stat(font_name)[6]:
raise RuntimeError("Invalid font file: " + font_name)
except OSError:
print("Could not find font file", font_name)
raise
except OverflowError:
# os.stat can throw this on boards without long int support
# just hope the font file is valid and press on
pass
def deinit(self):
"""Close the font file as cleanup."""
self._font.close()
def __enter__(self):
"""Initialize/open the font file"""
self.__init__()
return self
def __exit__(self, exception_type, exception_value, traceback):
"""cleanup on exit"""
self.deinit()
def draw_char(
self, char, x, y, framebuffer, color, size=1
): # pylint: disable=too-many-arguments
"""Draw one character at position (x,y) to a framebuffer in a given color"""
size = max(size, 1)
# Don't draw the character if it will be clipped off the visible area.
# if x < -self.font_width or x >= framebuffer.width or \
# y < -self.font_height or y >= framebuffer.height:
# return
# Go through each column of the character.
for char_x in range(self.font_width):
# Grab the byte for the current column of font data.
self._font.seek(2 + (ord(char) * self.font_width) + char_x)
try:
line = struct.unpack("B", self._font.read(1))[0]
except RuntimeError:
continue # maybe character isnt there? go to next
# Go through each row in the column byte.
for char_y in range(self.font_height):
# Draw a pixel for each bit that's flipped on.
if (line >> char_y) & 0x1:
framebuffer.fill_rect(
x + char_x * size, y + char_y * size, size, size, color
)
def width(self, text):
"""Return the pixel width of the specified text message."""
return len(text) * (self.font_width + 1)
class FrameBuffer1(FrameBuffer): # pylint: disable=abstract-method
"""FrameBuffer1 object. Inherits from FrameBuffer."""
And finally, you can run this example here:
"""
This example runs on an Seeed Xiao RP2040
"""
import board, time, os
import neopixel
import displayio
import framebufferio
displayio.release_displays()
# My custom version
from tile_framebuf import TileFramebuffer
pixel_pin = board.D6 # change this for different pinout
pixel_width = 32
pixel_height = 8
num_tiles = 2
pixels = neopixel.NeoPixel(
pixel_pin,
pixel_width * pixel_height * num_tiles, # dont forget to multiply for num_tiles
brightness=0.1, # keep the brightness low when using USB
auto_write=False,
)
pixel_framebuf = TileFramebuffer(
pixels,
pixel_width,
pixel_height,
num_tiles,
rotation = 0 # You can change this! Yeah!
)
pixel_framebuf.fill (0x000000)
pixel_framebuf.line (0, 15, 31, 0, 0xff0000)
pixel_framebuf.line (0,0, 31,15, 0x00ff00)
pixel_framebuf.circle (15,8,3,0x0000ff)
pixel_framebuf.display()
Bitmaps, Sprites, Gaming?The next step is to get support for Bitmaps and Sprites. I've found this library that works well for 8x8 bitmaps, but I'm still trying to tune it to recognize any Bitmap size and make the necessary adjustments automatically.
It is very interesting to see how the people at Adafruit configured the Displayio library to handle all types of displays, including LED Matrixes. The Pixel_Framebuf library is not integrated yet, but who knows in the future?
The example below uses a Thumb Joystick module connected to pins A0 and A1 (X and Y Axes) and also to pin D2 (Trigger). It is the first test with controlling the movement of pixels on the screen, which is an important element for game development.
The result itself is quite fun:
"""
This example runs on an Seeed Xiao RP2040
"""
import board, time, os
from analogio import AnalogIn
from digitalio import DigitalInOut, Direction, Pull
from simpleio import map_range
import neopixel
from rainbowio import colorwheel
import framebufferio
# My custom version
from tile_framebuf import TileFramebuffer
pixel_pin = board.D6
pixel_width = 32
pixel_height = 8
num_tiles = 2
joystick_x = AnalogIn(board.A0)
joystick_y = AnalogIn(board.A1)
trigger = DigitalInOut (board.D2)
trigger.direction = Direction.INPUT
trigger.pull = Pull.UP
pixels = neopixel.NeoPixel(
pixel_pin,
pixel_width * pixel_height * num_tiles, # dont forget to multiply for num_tiles
brightness=0.1,
auto_write=False,
)
pixel_framebuf = TileFramebuffer(
pixels,
pixel_width,
pixel_height,
num_tiles,
rotation = 3
)
def get_x(pin, number):
return map_range (pin.value, 200, 65535, - number //2 , number // 2)
def get_y(pin, number):
return map_range (pin.value, 65535, 200, - number //2 , number // 2)
old_x = pixel_width //2
old_y = pixel_height * num_tiles // 2
while True:
if (not trigger.value):
pixel_framebuf.fill (0x000000)
else:
x_pos = old_x + int (get_x (joystick_x, pixel_width //4))
y_pos = old_y + int (get_y (joystick_y, pixel_height // 2))
pixel_framebuf.line (old_y, old_x, y_pos, x_pos, colorwheel((time.monotonic()*50)%255))
pixel_framebuf.display()
time.sleep(0.1)
old_x = x_pos
old_y = y_pos
""" Tratando os limites da tela """
if (old_x < 0):
old_x = 0
if (old_x > pixel_width - 1):
old_x = pixel_width - 1
if (old_y < 0):
old_y = 0
if (old_y > pixel_height * num_tiles - 1):
old_y = pixel_height * num_tiles - 1
Some Bitmaps on ScreenI've added some Bitmap support, changing https://github.com/stuartm2/CircuitPython_BMP_Reader to read BMP File from any size.
class BMPReader(object):
def __init__(self, filename):
self._filename = filename
self._read_img_data()
def get_pixels(self):
pixel_grid = []
pixel_data = list(self._pixel_data) #Working with Copy
bytes_per_pixel = 3 # 24-bit color depth (RGB)
row_size = self.width * bytes_per_pixel
for y in range(self.height):
row = []
for x in range(self.width):
# Get Index
idx = (y * row_size) + (x * bytes_per_pixel)
b = pixel_data[idx]
g = pixel_data[idx + 1]
r = pixel_data[idx + 2]
row.append((r, g, b))
pixel_grid.append(row)
return pixel_grid
def _read_img_data(self):
def lebytes_to_int(bytes):
n = 0x00
for b in reversed(bytes):
n = (n << 8) | b
return n
with open(self._filename, 'rb') as f:
img_bytes = bytearray(f.read())
# Check BMP Format
assert img_bytes[0:2] == b'BM', "Not Valid BMP - Try https://online-converting.com/image/convert2bmp"
assert lebytes_to_int(img_bytes[30:34]) == 0, \
"Compression is not supported"
assert lebytes_to_int(img_bytes[28:30]) == 24, \
"Only 24 bits color depth is supported"
# Get image dimensions
start_pos = lebytes_to_int(img_bytes[10:14])
self.width = lebytes_to_int(img_bytes[18:22])
self.height = lebytes_to_int(img_bytes[22:26])
# Get Pixel data
self._pixel_data = img_bytes[start_pos:]
Running this code (just changing BMP filename):
import time
import board
import neopixel_spi as neopixel
from bmp_reader import BMPReader
friend_img = BMPReader ("sesc-logo.bmp") # My BMP File
friend = friend_img.get_pixels()
# My custom version
from tile_framebuf import TileFramebuffer
spi = board.SPI()
pixel_pin = board.D10
pixel_width = 32
pixel_height = 8
num_tiles = 2
num_pixels = pixel_width * pixel_height * num_tiles
# Y offset, from the top of the display
offset = 2
# whether to mirror horizontally (test both values)
upside_down = True
pixels = neopixel.NeoPixel_SPI(
spi,
pixel_width * pixel_height * num_tiles, # dont forget to multiply for num_tiles
brightness=0.2,
auto_write=False,
)
screen = TileFramebuffer(
pixels,
pixel_width,
pixel_height,
num_tiles,
rotation = 0
)
for x in range (friend_img.width):
for y in range (friend_img.height):
screen.pixel (y,x,friend[y][x])
screen.display()
I wrote a few more tutorials with my research:
Bitmap Animation: https://www.hackster.io/nicolaudosbrinquedos/circuitpython-neopixel-bmp-animation-e2ca24
Marquee: https://www.hackster.io/nicolaudosbrinquedos/circuitpython-tiled-marquee-6e48cf
Maze Game: https://www.hackster.io/nicolaudosbrinquedos/circuitpython-neopixel-maze-game-98f9cd
Stay connected! While the Sesc workshops last, I will post my experiences with this unusual display. I'm studying some versions of Snake, Maze codes. For my work, I need to find versions of these games that run in text mode only, without Pygame or any other resource.
If you have any recommendations, let me know here ;)
Comments