Files
lamatrix/ledmatrix.py
2018-12-21 08:12:54 +00:00

249 lines
7.4 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
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:
rotation = 180
# Reduce brightness by scaling down colors
brightness_scaler = 32
rows = 0
columns = 0
driver = None
fb = []
def __init__(self, driver, columns = 8, rows = 8, rotation = 0):
self.driver = driver
self.columns = columns
self.rows = rows
self.num_pixels = rows * columns
self.num_modified_pixels = self.num_pixels # optimization: avoid rendering too many pixels
assert rows == 8, "Calculations in xy_to_phys expect 8 rows"
self.rotation = (360 + rotation) % 360
# This is laid out in physical order
self.fb.append(bytearray(self.num_pixels*3))
self.fb.append(bytearray(self.num_pixels*3))
self.fb_index = 0
# Optimize clear
self.fb.append(bytearray(self.num_pixels*3))
for i in range(len(self.fb[0])):
self.fb[0][i] = 1
# Initialize display
self.driver.init_display(self.num_pixels)
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.rows-1-y
y = tmp
elif self.rotation < 270:
x = self.columns-1-x
y = self.rows-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.
#
# Here we calculate the physical offset for the desired rotation, with
# the assumption that the first LED is at (0,0).
# We'll need this adjusting for the north-south-south-north layout
cycle = self.rows << 1 # optimization: twice the number of rows
# First we determine which "block" (of a complete cyle) the pixel is in
nssn_block = x >> 1 # optimization: divide by two
phys_addr = nssn_block << 4 # optimization: Multiply by cycle
# Second we determine if the column has decreasing or increasing addrs
is_decreasing = x & 1
if is_decreasing:
phys_addr += cycle - 1 - y
else:
phys_addr += y
return phys_addr
def phys_to_xy(self, phys_addr):
"""
Map physical LED address to x,y after accounting for display rotation
"""
x = phys_addr >> 3 # optimization: divide by number of rows
cycle = self.rows << 1 # optimization: twice the number of rows
y = phys_addr & (cycle-1) # optimization: modulo the cycle
if y >= self.rows:
y = cycle - 1 - y
if self.rotation < 90:
pass
elif self.rotation < 180:
tmp = x
x = self.rows-1-y
y = tmp
elif self.rotation < 270:
x = self.columns-1-x
y = self.rows-1-y
else:
tmp = x
x = y
y = self.columns-1-tmp
return [x, y]
def get_pixel(self, x, y):
"""
Get pixel from the currently displayed frame buffer
"""
pixel = self.xy_to_phys(x, y)
back_index = (self.fb_index+1)%2
offset = pixel*3
return [self.fb[back_index][offset+0], self.fb[back_index][offset+1], self.fb[back_index][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)
back_index = (self.fb_index)%2
offset = pixel*3
return [self.fb[back_index][offset+0], self.fb[back_index][offset+1], self.fb[back_index][offset+2]]
def put_pixel(self, x, y, r, g, b):
"""
Set pixel ni the to-be-displayed frame buffer"
"""
if x >= self.columns or y >= self.rows:
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
"""
self.fb_index ^= 1
self.fb[self.fb_index][:] = self.fb[2][:]
# Optimization: keep track of last updated pixel
self.num_modified_pixels = self.num_pixels
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]
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]:
self.driver.put_pixel(pixel, r // self.brightness_scaler, g // self.brightness_scaler, b // self.brightness_scaler)
num_rendered += 1
t0 = time.ticks_ms() - t0
# This takes 52ms
t1 = time.ticks_ms()
self.driver.update_display(self.num_modified_pixels)
t1 = time.ticks_ms() - t1
#time.sleep(0.00004 * self.columns * self.rows)
#time.sleep_ms(10)
# This takes 0ms
self.fb_index ^= 1
self.fb[self.fb_index][:] = self.fb[self.fb_index^1]
print('LedMatrix render: {} pixels updated in {}ms, spent {}ms in driver update call, total {}ms'.format(num_rendered, t0, t1, time.ticks_ms() - tX))
# Optimization: keep track of last updated pixel
self.num_modified_pixels = 0
def scrollout(self):
"""
Scene transition effect: scroll away pixels
"""
for i in range(self.rows):
for x in range(self.columns):
self.put_pixel(x, i, 0, 0, 0)
for y in range(self.rows-1):
for x in range(self.columns):
colors = self.get_pixel(x, y)
self.put_pixel(x, y+1, colors[0], colors[1], colors[2])
self.render()
#time.sleep(0.05)
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 i in range(self.columns*self.rows):
colors = self.get_pixel(i % self.columns, i // self.columns)
if colors[0] or colors[1] or colors[2]:
active_pixels += 1
if not active_pixels:
# No more pixels to dissolve
return False
per_pixel_sleep = (0.1-0.00003*self.num_pixels)/active_pixels
pixel = 1
for i in range(256):
bit = pixel & 1
pixel >>= 1
if bit:
pixel ^= 0xb4
if pixel >= self.columns*self.rows:
continue
colors = self.get_pixel(pixel % self.columns, pixel // self.columns)
if not colors[0] and not colors[1] and not colors[2]:
continue
self.put_pixel(pixel % self.columns, pixel // self.columns, 0, 0, 0)
self.render()
time.sleep(per_pixel_sleep)
# There are still pixels to dissolve
return True