Files
lamatrix/ledmatrix.py
2021-01-07 10:48:20 +01:00

341 lines
9.8 KiB
Python
Executable File

# This file implements high-level routines for using a LED matrix as a display.
#
# While this code is primarily designed to be running on the host computer,
# MCUs running MicroPython will run this code as well to provide automatic
# rendering of the current time while the host computer is offline.
#
# The constructor needs to be called with a HAL (Hardware Abstraction Layer)
# driver which provides low-level access to the display. The HAL can be
# e.g. a driver that implements a serial protocol running on an MCU.
#
import time
import math
if not hasattr(time, 'ticks_ms'):
# Emulate https://docs.pycom.io/firmwareapi/micropython/utime.html
time.ticks_ms = lambda: int(time.time()*1000)
class LedMatrix:
def __init__(self, driver, config):
self.driver = driver
self.debug = False
self.stride = 8
self.columns = 32
self.rotation = 0
self.fps = 10
self.fix_r = 0xff
self.fix_g = 0xff
self.fix_b = 0xc0
self.panelcolumn = 8
if config:
if 'debug' in config:
self.debug = config['debug']
if 'stride' in config:
self.stride = config['stride']
if 'columns' in config:
self.columns = config['columns']
if 'rotation' in config:
self.rotation = (360 + config['rotation']) % 360
if 'fps' in config:
self.fps = config['fps']
self.num_pixels = self.stride * self.columns
# For avoiding multiplications and divisions
self.num_modified_pixels = self.num_pixels # optimization: avoid rendering too many pixels
# This is laid out in physical order
self.fb = [
bytearray(self.num_pixels*3),
bytearray(self.num_pixels*3),
]
self.fb_index = 0
# Initialize display
self.driver.init_display(self.num_pixels)
def round_down(self, n, decimals=0):
multiplier = 10 ** decimals
return math.floor(n * multiplier) / multiplier
def xy_to_phys(self, x, y):
"""
Map x,y to physical LED address after accounting for display rotation
"""
if self.rotation < 90:
pass
elif self.rotation < 180:
tmp = x
x = self.stride-1-y
y = tmp
elif self.rotation < 270:
x = self.columns-1-x
y = self.stride-1-y
else:
tmp = x
x = y
y = self.columns-1-tmp
# The LEDs are laid out in a long string going from north to south,
# one step to the east, and then south to north, before the cycle
# starts over.
stride = self.stride
phys_addr = ((x-1)%self.panelcolumn) + ((y-1)*self.panelcolumn) + (self.round_down((x-1)/self.panelcolumn) * self.panelcolumn * self.panelcolumn)
#MOD(x-1;panelCol) + ((y-1)*panelCol) + (FLOOR.MATH((x-1)/panelCol;1)*panelCol*panelRow)
# if x & 1:
# phys_addr += stride - 1 - y
# else:
# phys_addr += y
# return phys_addr
def get_pixel(self, x, y):
"""
Get pixel from the currently displayed frame buffer
"""
pixel = self.xy_to_phys(x, y)
fb_id = (self.fb_index+1)%2
offset = pixel*3
return [self.fb[fb_id][offset+0], self.fb[fb_id][offset+1], self.fb[fb_id][offset+2]]
def get_pixel_front(self, x, y):
"""
Get pixel from the to-be-displayed frame buffer
"""
pixel = self.xy_to_phys(x, y)
fb_id = (self.fb_index)%2
offset = pixel*3
return [self.fb[fb_id][offset+0], self.fb[fb_id][offset+1], self.fb[fb_id][offset+2]]
def put_pixel(self, x, y, r, g, b):
"""
Set pixel ni the to-be-displayed frame buffer"
"""
if x > self.columns:
# TODO: proper fix for 16x16 displays
x -= self.stride
y += 8
if x >= self.columns or y >= self.stride:
return
pixel = self.xy_to_phys(x, y)
offset = pixel*3
self.fb[self.fb_index][offset+0] = int(r)
self.fb[self.fb_index][offset+1] = int(g)
self.fb[self.fb_index][offset+2] = int(b)
# Optimization: keep track of last updated pixel
if pixel >= self.num_modified_pixels:
self.num_modified_pixels = pixel+1
def clear(self):
"""
Clear the frame buffer by setting all pixels to black
"""
buf = self.fb[self.fb_index]
for i in range(self.num_pixels*3):
buf[i] = 0
self.num_modified_pixels = self.num_pixels
def render_block(self, data, rows, cols, x, y):
"""
Put a block of data of rows*cols*3 size at (x,y)
"""
if x+cols > self.columns or y+rows > self.stride:
return
offset = 0
for row in range(rows):
for col in range(cols):
self.put_pixel(x+col, y+row, data[offset], data[offset+1], data[offset+2])
offset += 3
def render_text(self, font, text, x_off, y_off, intensity=32):
"""
Render text with the pixel font
"""
put_pixel_fn = self.put_pixel
w = font.width
h = font.height
alphabet = font.alphabet
font_data = font.data
in_r = self.fix_r * intensity // 255
in_g = self.fix_g * intensity // 255
in_b = self.fix_b * intensity // 255
low_r = in_r >> 1
low_g = in_g >> 1
low_b = in_b >> 1
for i in range(len(text)):
digit = text[i]
if digit in '.:-\' ' or (i and text[i-1] in '.: '):
x_off -= 1
data_offset = alphabet.find(digit)
if data_offset < 0:
data_offset = 0
tmp = data_offset * w * h
font_byte = tmp >> 3
font_bit = tmp & 7
for row in range(h):
for col in range(w):
if font_data[font_byte] & (1<<font_bit):
put_pixel_fn(x_off+col, y_off+row, in_r, in_g, in_b)
else:
put_pixel_fn(x_off+col, y_off+row, 0, 0, 0)
font_bit += 1
if font_bit == 8:
font_byte += 1
font_bit = 0
if digit == 'm':
put_pixel_fn(x_off+1, y_off+1, low_r, low_g, low_b)
elif digit == 'w':
put_pixel_fn(x_off+1, y_off+3, low_r, low_g, low_b)
elif digit == 'n':
put_pixel_fn(x_off, y_off+3, low_r, low_g, low_b)
put_pixel_fn(x_off+2, y_off+1, low_r, low_g, low_b)
x_off += w
def render(self):
"""
Render the to-be-displayed frame buffer by making put_pixel() and
render() calls down to the HAL driver.
"""
# This takes 11ms
tX = t0 = time.ticks_ms()
front = self.fb[self.fb_index]
back = self.fb[self.fb_index ^ 1]
put_pixel = self.driver.put_pixel
num_rendered = 0
for pixel in range(self.num_modified_pixels):
# This crap saves about 4ms
i = pixel*3
j = i+1
k = j+1
r = front[i]
g = front[j]
b = front[k]
if r != back[i] or g != back[j] or b != back[k]:
put_pixel(pixel, r, g, b)
num_rendered += 1
t1 = time.ticks_ms()
t0 = t1 - t0
# This takes 52ms
self.driver.update_display(self.num_modified_pixels)
t2 = time.ticks_ms()
t1 = t2 - t1
# This takes 0ms
self.fb_index ^= 1
self.fb[self.fb_index][:] = self.fb[self.fb_index^1]
# Optimization: keep track of last updated pixel
self.num_modified_pixels = 0
if self.debug:
print('LedMatrix render: {} driver.put_pixel() in {}ms, spent {}ms in driver.update_display(), total {}ms'.format(num_rendered, t0, t1, t2 - tX))
def hscroll(self, distance=4):
"""
Scroll away pixels, left or right
"""
if distance > 0:
z_start, z_end, delta = 0, self.columns, -1
else:
z_start, z_end, delta = self.columns-1, -1, 1
if self.columns % distance:
distance -= delta
for zero_lane in range(z_start, z_end, distance):
fb_cur = self.fb[self.fb_index^1]
fb_next = self.fb[self.fb_index]
for y in range(self.stride):
for x in range(z_end+delta, zero_lane+distance+delta, delta):
src = self.xy_to_phys(x-distance, y)*3
dst = self.xy_to_phys(x, y)
if dst >= self.num_modified_pixels:
self.num_modified_pixels = dst+1
dst *= 3
fb_next[dst] = fb_cur[src]
fb_next[dst+1] = fb_cur[src+1]
fb_next[dst+2] = fb_cur[src+2]
for y in range(self.stride):
for x in range(zero_lane, zero_lane+distance, -delta):
dst = self.xy_to_phys(x, y)
if dst >= self.num_modified_pixels:
self.num_modified_pixels = dst+1
dst *= 3
fb_next[dst] = fb_next[dst+1] = fb_next[dst+2] = 0
self.render()
def vscroll(self, distance=2):
"""
Scroll away pixels, up or down
"""
if distance > 0:
z_start, z_end, delta = 0, self.stride, -1
else:
z_start, z_end, delta = self.stride-1, -1, 1
if self.stride % distance:
distance -= delta
for zero_lane in range(z_start, z_end, distance):
fb_cur = self.fb[self.fb_index^1]
fb_next = self.fb[self.fb_index]
for y in range(z_end+delta, zero_lane+distance+delta, delta):
for x in range(self.columns):
src = self.xy_to_phys(x, y-distance)*3
dst = self.xy_to_phys(x, y)
if dst >= self.num_modified_pixels:
self.num_modified_pixels = dst+1
dst *= 3
fb_next[dst] = fb_cur[src]
fb_next[dst+1] = fb_cur[src+1]
fb_next[dst+2] = fb_cur[src+2]
for y in range(zero_lane, zero_lane+distance, -delta):
for x in range(self.columns):
dst = self.xy_to_phys(x, y)
if dst >= self.num_modified_pixels:
self.num_modified_pixels = dst+1
dst *= 3
fb_next[dst] = fb_next[dst+1] = fb_next[dst+2] = 0
self.render()
return False
def fade(self):
"""
Scene transition effect: fade out active pixels
"""
while True:
light = 0
for i in range(self.num_pixels):
colors = self.get_pixel(i % self.columns, i // self.columns)
colors[0] = colors[0] >> 2
colors[1] = colors[1] >> 2
colors[2] = colors[2] >> 2
light |= colors[0]+colors[1]+colors[2]
self.put_pixel(i % self.columns, i // self.columns, colors[0], colors[1], colors[2])
self.render()
time.sleep(0.1)
if not light:
# Everything has faded out
return False
def dissolve(self):
"""
Scene transition effect: dissolve active pixels with LFSR
"""
active_pixels = 0
for y in range(self.stride):
for x in range(self.columns):
colors = self.get_pixel(x, y)
if colors[0] or colors[1] or colors[2]:
active_pixels += 1
if not active_pixels:
return False
pixel = 1
for i in range(256):
bit = pixel & 1
pixel >>= 1
if bit:
pixel ^= 0xb4
x, y = pixel % self.columns, pixel // self.columns
colors = self.get_pixel(x, y)
if not colors[0] and not colors[1] and not colors[2]:
continue
self.put_pixel(x, y, 0, 0, 0)
if i % 4 == 3:
self.render()
# There are still pixels to dissolve
return True