Oops, remove the prior-attempt-fail code

This commit is contained in:
Phillip Burgess 2020-05-05 10:45:42 -07:00
parent a2db1224f4
commit 57da60c874
3 changed files with 0 additions and 838 deletions

View file

@ -1,527 +0,0 @@
"""
Light painting project for Adafruit CLUE using NeoPixel or DotStar strip.
Images should be in 24-bit BMP format, with height matching the length
of the LED strip. The ulab module is used to assist with interpolation
and dithering, displayio for a minimal user interface.
"""
# pylint: disable=import-error
import gc
from math import modf
from time import monotonic, sleep
import board
import busio
import digitalio
import displayio
import ulab
from ubmp import UBMP, BMPError
from neopixel_write import neopixel_write
from richbutton import RichButton
from adafruit_display_text import label
from adafruit_display_shapes.rect import Rect
from terminalio import FONT # terminalio font is crude but fast to display
FONT_WIDTH, FONT_HEIGHT = FONT.get_bounding_box()
# These are permanent global settings, can only change by editing the code:
FLIP_SCREEN = True # If True, turn CLUE screen & buttons upside-down
PATH = '/bmps-30px' # Folder containing BMP images (or '' for root path)
GAMMA = 2.6 # Correction factor for perceptually linear brightness
NUM_PIXELS = 30 # LED strip length, half-meter is usu. 30 or 72 pixels
PIXEL_PIN = board.D0 # Output pin for NeoPixel data
PIXEL_ORDER = 'grb' # Most NeoPixels are GRB data order, otherwise set here
# IF USING DOTSTARS, USE THESE VALUES FOR PIXEL_PIN AND PIXEL_ORDER INSTEAD:
#PIXEL_PIN = board.SDA, board.SCL # Data, clock pins if using DotStars
#PIXEL_ORDER = 'brg' # Color order for DotStars
def centered_label(text, y_pos, scale):
"""
Create a displayio label that's horizontally centered on screen.
Arguments:
text (string) : Label string.
y_pos (int) : Vertical position on screen.
scale (int) : Text scale.
Returns: displayio group object.
"""
group = displayio.Group(scale=scale, x=board.DISPLAY.width // 2)
x_pos = len(text) * FONT_WIDTH // -2
group.append(label.Label(FONT, text=text, x=x_pos, y=y_pos))
return group
# pylint: disable=too-many-instance-attributes
class ClueLightPainter:
"""
CLUE Light Painter is wrapped in this class to avoid a bunch more globals.
"""
# pylint: disable=too-many-arguments
def __init__(self, flip, path, num_pixels, pixel_order, pixel_pin, gamma):
"""
App constructor. Follow up with a call to ClueLightPainter.run().
Arguments:
flip (boolean) : If True, CLUE display and buttons are
flipped 180 degrees from normal (makes
wiring easier in some situations).
path (string) : Directory containing BMP images.
num_pixels (int) : LED strip length.
pixel_order (string) : LED data order, e.g. 'grb'.
pixel_pin (int/tuple) : Board pin(s) for LED data output. If a
single value (int), a NeoPixel strip is
being used. If two values (tuple or
list), it's a DotStar strip (pins are
data and clock of an SPI port).
gamma (float) : Correction for perceptual linearity.
"""
self.neobmp = UBMP(num_pixels, pixel_order)
self.path = path
self.num_pixels = num_pixels
self.gamma = gamma
# Above values are permanently one-time set. The following values
# can be reconfigured mid-run.
self.image_num = 0 # Current image index in self.path
self.loop = False # Repeat image playback
self.brightness = 1.0 # LED brightness, 0.0 (off) to 1.0 (bright)
self.config_mode = 0 # Current setting being changed
self.rect = None # Multipurpose progress/setting rect
self.speed = 0.6 # Paint speed, 0.0 (slow) to 1.0 (fast)
self.columns = [] # Empty image data
if isinstance(pixel_pin, (tuple, list)):
# Using DotStar LEDs. The SPI peripheral is locked and config'd
# once here and never relinquished, to save some time on every
# column (need them ussued as fast as possible).
self.spi = busio.SPI(pixel_pin[1], MOSI=pixel_pin[0])
self.spi.try_lock()
self.spi.configure(baudrate=16000000)
self.write_func = self.dotstar_write
self.neopixel_pin = None # Required for self.write_func() calls
else:
# Using NeoPixel LEDs
self.neopixel_pin = digitalio.DigitalInOut(pixel_pin)
self.neopixel_pin.direction = digitalio.Direction.OUTPUT
self.write_func = neopixel_write
# Configure hardware initial state
self.button_left = RichButton(board.BUTTON_A)
self.button_right = RichButton(board.BUTTON_B)
if flip:
board.DISPLAY.rotation = 180
self.button_left, self.button_right = (self.button_right,
self.button_left)
else:
board.DISPLAY.rotation = 0
# Turn off onboard NeoPixel and LED strip
onboard_pixel_pin = digitalio.DigitalInOut(board.NEOPIXEL)
onboard_pixel_pin.direction = digitalio.Direction.OUTPUT
neopixel_write(onboard_pixel_pin, bytearray(3))
self.clear_strip()
# Get list of compatible BMP images in path
self.images = self.neobmp.scandir(path)
if not self.images:
group = displayio.Group()
group.append(centered_label('NO IMAGES', 40, 3))
board.DISPLAY.show(group)
while True:
pass
# Load first image in list
self.load_image()
# Clear display
board.DISPLAY.show(displayio.Group())
def dotstar_write(self, _, data):
"""
DotStar strip data-writing wrapper. Accepts color data packed as
3 bytes/pixel (a la NeoPixel), repackages it into DotStar format
(header, per-pixel marker, footer) and outputs to self.spi.
Arguments:
_ (None) : Unused but required argument, for 1:1 calling
parity with neopixel_write() (where the first
argument is a pin number). Allows a single
common function call anywhere LEDs are updated
rather than if/else in every location.
data (bytearray) : Pixel data in LED strip's native color order,
3 bytes/pixel. Also takes ulab uint8 ndarray.
"""
pixel_start = bytearray([255]) # Per-pixel marker
data_bytes = [x for l in [pixel_start + data[i:i+3]
for i in range(0, len(data), 3)] for x in l]
# SPI is NOT locked or configured here -- the application performs
# that once at startup and never relinquishes control of the port.
# Anything to save a few cycles.
self.spi.write(bytearray([0] * 4) + bytearray(data_bytes) +
bytearray([255] * (((len(data) // 3) + 15) // 16)))
def clear_strip(self):
"""
Turn off all LEDs of the NeoPixel/DotStar strip.
"""
# Though most strips are 3 bytes/pixel, issue 4 bytes just in case
# someone's using RGBW NeoPixel strip (the painting code never
# uses the W byte, but handle it regardless, in case RGBW strip is
# all someone has). So this will issue 1/3 more data than is really
# needed in most cases (including DotStar), but little harm done as
# this is only called when clearing the strip, not when painting.
# The extra unused bits harmlessly fall off the end of the strip.
self.write_func(self.neopixel_pin, bytearray(self.num_pixels * 4))
def load_progress(self, amount):
"""
Callback function for image loading, moves progress bar on display.
Arguments:
amount (float) : Current 'amount loaded' coefficient; 0.0 to 1.0
"""
self.rect.x = int(board.DISPLAY.width * (amount - 1.0))
def load_image(self):
"""
Load BMP from image list, determined by variable self.image_num
(not a passed argument). Data is converted and placed in variable
self.columns[].
"""
# Minimal progress display while image is loaded.
group = displayio.Group()
group.append(centered_label('LOADING...', 30, 3))
self.rect = Rect(-board.DISPLAY.width, 120,
board.DISPLAY.width, 40, fill=0x00FF00)
group.append(self.rect)
board.DISPLAY.show(group)
try:
self.columns = self.neobmp.load(self.path + '/' +
self.images[self.image_num],
self.load_progress)
except (MemoryError, BMPError):
group = displayio.Group()
group.append(centered_label('TOO BIG', 40, 3))
board.DISPLAY.show(group)
sleep(4)
board.DISPLAY.show(displayio.Group()) # Clear display
def paint(self):
"""
Paint mode. Watch for button taps to start/stop image playback,
or button hold to switch to config mode. During playback, do all
the nifty image processing.
"""
if not self.columns: # If no image loaded
return # Go back to config, can try another
board.DISPLAY.brightness = 0 # Screen backlight OFF
painting = False
duration = 5.0 - self.speed * 4.5 # 0.5 to 5 seconds
gc.collect() # Helps make playback a little smoother
while True:
action_set = {self.button_left.action(),
self.button_right.action()}
if RichButton.TAP in action_set:
if painting: # If currently painting
self.clear_strip() # Turn LEDs OFF
else:
start_time = monotonic()
err = 0 # Clear 'error term' used for dithering
painting = not painting # Toggle paint mode on/off
elif RichButton.HOLD in action_set:
return # Exit painting, enter config mode
if painting:
elapsed = monotonic() - start_time
if self.loop:
elapsed %= duration
elif elapsed > duration:
self.clear_strip()
painting = False
continue
# Current absolute position along image, as floating-point
# value from 0.0 (first column) to last column.
position = elapsed / duration # 0.0 to 1.0
if self.loop:
position *= len(self.columns) # 0 to image width
else:
position *= (len(self.columns) - 1) # 0 to last column
# Separate the absolute position into three values:
# the relative 'weight' of the subsequent image column
# when interpolating between two, the integer index of the
# first column and integer index of second column.
weight_2, column_1 = modf(position)
column_1 = int(column_1)
column_2 = (column_1 + 1) % len(self.columns)
weight_1 = 1.0 - weight_2
# Pixel values are stored as bytes from 0-255.
# Gamma correction requires floats from 0.0 to 1.0.
# So there's going to be a scaling operation involved,
# BUT, as configurable LED brightness is also a thing,
# we can work that into the same operation. Rather than
# dividing pixels by 255, multiply by brightness / 255.
# This reduces the two column interpolation weightings
# from 0.0-1.0 to 0.0-brightness/255.
weight_1 *= self.brightness / 255
weight_2 *= self.brightness / 255
# 'want' is an ndarray of the idealized (as in,
# floating-point) pixel values resulting from the
# interpolation, with gamma correction applied and
# scaled back up to the 0-255 range.
want = ((self.columns[column_1] * weight_1 +
self.columns[column_2] * weight_2) **
self.gamma * 255.001)
# 'got' will be an ndarray of the values that get issued to
# the LED strip, formed through several operations. First,
# an 'error term' is added to each pixel, representing how
# 'wrong' the prior output was. This is used for error
# diffusion dithering. 'got' is floating-point at this stage.
got = ulab.array(want + err)
# The error term may push some pixel values outside the
# required 0-255 range, so clip the result (aka 'saturate').
# (Note to future self: requested a clip() function in ulab,
# if that gets added in some future release, that can be
# used here instead of these two Python ops.)
got[got < 0] = 0
got[got > 255] = 255
# Now quantize the floating-point 'got' to uint8 type.
# This represents the actual final byte values that will
# be issued to the LED strip.
got = ulab.array(got, dtype=ulab.uint8)
# Make note of the difference...the 'error term'...between
# what we ideally wanted (float) and what we actually got
# (dithered, clipped and quantized). This will get used on
# the next pass through the loop. Don't keep 100% of the
# value, or image 'shimmers' too much...dial back slightly.
err = (want - got) * 0.9
# Issue the resulting uint8 'got' data to the LED strip.
self.write_func(self.neopixel_pin, got)
# Each config screen is broken out into its own function...
# Generates its UI, handles button interactions, clears screen.
# It was a toss-up between this and one big multimodal config
# function. This way definitely generates less pylint gas pains.
# Also, creating and destroying elements (rather than creating
# them all up-front and showing or hiding elements as needed)
# tends to use less RAM, leaving more for image.
def make_ui_group(self, main_config, config_label, rect_val=None):
"""
Generates and displays a displayio group containing several elements
that all config screens have in common (or nearly in common).
Arguments:
main_config (boolean) : If true, function generates the main
config screen elements, else makes
elements for other config screens.
config_label (string) : Text to appear at center(ish) of screen.
rect_val (float) : If specified, a Rect object is created
whose width represents the value.
0.0 = min, 1.0 = full display width.
Returns: displayio group
"""
group = displayio.Group(max_size=6)
group.append(centered_label('TAP L/R to', 3, 2))
group.append(centered_label('select item' if main_config else
'select image' if self.config_mode is 0
else 'change', 16, 2))
group.append(centered_label('HOLD L: item config' if main_config else
'HOLD L: back', 100, 2))
group.append(centered_label('HOLD R: paint', 113, 2))
if rect_val:
self.rect = Rect(int(board.DISPLAY.width * (rect_val - 1.0)),
120, board.DISPLAY.width, 40, fill=0x00FF00)
group.append(self.rect)
# Config label always appears as last item in group
# so calling func can pop() and replace it if need be.
group.append(centered_label(config_label, 30 if rect_val else 40, 3))
board.DISPLAY.show(group)
return group
def config_select(self):
"""
Initial configuration screen, in which the user selects which
setting will be changed. Tap L/R to select which setting,
hold L to change that setting, or hold R to resume painting.
"""
self.clear_strip()
strings = ['IMAGE', 'SPEED', 'LOOP', 'BRIGHTNESS']
funcs = [self.config_image, self.config_speed, self.config_loop,
self.config_brightness]
group = self.make_ui_group(True, strings[self.config_mode])
board.DISPLAY.brightness = 1 # Screen on
prev_mode = self.config_mode
reload_image = not self.columns
while True:
action_left, action_right = (self.button_left.action(),
self.button_right.action())
if action_left is RichButton.HOLD:
# Call one of the configuration sub-menu functions.
# These all return two booleans. One indicates whether
# the setting change requires reloading the image,
# other indicates if it was a R button hold, in which
# case this should return to paint mode.
reload, paint = funcs[self.config_mode]()
# Image reload is not immediate, it can wait until
# returning to paint.
reload_image |= reload
if paint:
break # Exit loop, resume paint
else:
board.DISPLAY.show(group) # Put config UI back up
elif action_right is RichButton.HOLD:
break
elif action_left is RichButton.TAP:
self.config_mode = (self.config_mode - 1) % len(strings)
elif action_right is RichButton.TAP:
self.config_mode = (self.config_mode + 1) % len(strings)
if self.config_mode is not prev_mode:
# Create/destroy mode descriptions as needed
group.pop()
group.append(centered_label(strings[self.config_mode],
40, 3))
prev_mode = self.config_mode
# Before exiting to paint mode, check if new image needs loaded
if reload_image:
self.load_image()
def config_image(self):
"""
Image select screen. Tap L/R to cycle among image filenames,
hold L to go back to main config menu, hold R to paint.
Returns: two booleans, first indicates whether image needs to
be reloaded, second indicates if returning to paint mode vs
more config.
"""
orig_image = self.image_num
prev_image = self.image_num
group = self.make_ui_group(False, self.images[self.image_num])
while True:
action_left, action_right = (self.button_left.action(),
self.button_right.action())
if action_left is RichButton.HOLD:
# Resume config
return self.image_num is not orig_image, False
if action_right is RichButton.HOLD:
# Resume paint
return self.image_num is not orig_image, True
if action_left is RichButton.TAP:
self.image_num = (self.image_num - 1) % len(self.images)
elif action_right is RichButton.TAP:
self.image_num = (self.image_num + 1) % len(self.images)
if self.image_num is not prev_image:
group.pop()
group.append(centered_label(self.images[self.image_num],
40, 3))
prev_image = self.image_num
def config_speed(self):
"""
Speed select screen. Tap L/R to decrease/increase paint speed,
hold L to go back to main config menu, hold R to paint.
Returns: two booleans, first is always False, second indicates
if returning to paint mode vs more config.
"""
prev_speed = self.speed
self.make_ui_group(False, 'Speed:', self.speed)
while True:
action_left, action_right = (self.button_left.action(),
self.button_right.action())
if action_left is RichButton.HOLD:
return False, False # Resume config
if action_right is RichButton.HOLD:
return False, True # Resume paint
if action_left is RichButton.TAP:
self.speed = max(0, self.speed - 0.1)
elif action_right is RichButton.TAP:
self.speed = min(10, self.speed + 0.1)
if self.speed is not prev_speed:
self.rect.x = int(board.DISPLAY.width * (self.speed - 1.0))
prev_speed = self.speed
def config_loop(self):
"""
Loop select screen. Tap L/R to toggle looping on/off,
hold L to go back to main config menu, hold R to paint.
Returns: two booleans, first is always False, second indicates
if returning to paint mode vs more config.
"""
loop_label = ['Loop OFF', 'Loop ON']
group = self.make_ui_group(False, loop_label[self.loop])
while True:
action_left, action_right = (self.button_left.action(),
self.button_right.action())
if action_left is RichButton.HOLD:
return False, False # Resume config
if action_right is RichButton.HOLD:
return False, True # Resume paint
if RichButton.TAP in {action_left, action_right}:
self.loop = not self.loop
group.pop()
group.append(centered_label(loop_label[self.loop], 40, 3))
def config_brightness(self):
"""
Brightness select screen. Tap L/R to decrease/increase brightness,
hold L to go back to main config menu, hold R to paint.
Returns: two booleans, first is always False, second indicates
if returning to paint mode vs more config.
"""
prev_brightness = self.brightness
self.make_ui_group(False, 'Brightness:', self.brightness)
while True:
action_left, action_right = (self.button_left.action(),
self.button_right.action())
if action_left is RichButton.HOLD:
return False, False # Resume config
if action_right is RichButton.HOLD:
return False, True # Resume paint
if action_left is RichButton.TAP:
self.brightness = max(0.0, self.brightness - 0.1)
elif action_right is RichButton.TAP:
self.brightness = min(1.0, self.brightness + 0.1)
if self.brightness is not prev_brightness:
self.rect.x = int(board.DISPLAY.width * (self.brightness - 1.0))
prev_brightness = self.brightness
def run(self):
"""
Application loop just consists of alternating paint and
config modes. Each function has its own condition for return
(switching to the opposite mode). Repeat forever.
"""
while True:
self.paint()
self.config_select()
ClueLightPainter(FLIP_SCREEN, PATH,
NUM_PIXELS, PIXEL_ORDER, PIXEL_PIN, GAMMA).run()

View file

@ -1,94 +0,0 @@
"""Glorified button class with debounced tap, double-tap, hold and release"""
# pylint: disable=import-error
from time import monotonic
from digitalio import DigitalInOut, Direction, Pull
# pylint: disable=too-many-instance-attributes, too-few-public-methods
class RichButton:
"""
A button class handling more than basic taps: adds debounced tap,
double-tap, hold and release.
"""
TAP = 0
DOUBLE_TAP = 1
HOLD = 2
RELEASE = 3
def __init__(self, pin, *, debounce_period=0.05, hold_period=0.75,
double_tap_period=0.3):
"""
Constructor for RichButton class.
Arguments:
pin (int) : Digital pin connected to button
(opposite leg to GND). Pin will be
configured as INPUT with pullup.
Keyword arguments:
debounce_period (float) : interval, in seconds, in which multiple
presses are ignored (debounced)
(default = 0.05 seconds).
hold_period (float) : interval, in seconds, when a held
button will return a HOLD value from
the action() function (default = 0.75).
double_tap_period (float): interval, in seconds, when a double-
tap can be sensed (vs returning
a second single-tap) (default = 0.3).
Longer double-tap periods will make
single-taps less responsive.
"""
self.in_out = DigitalInOut(pin)
self.in_out.direction = Direction.INPUT
self.in_out.pull = Pull.UP
self._debounce_period = debounce_period
self._hold_period = hold_period
self._double_tap_period = double_tap_period
self._holding = False
self._tap_time = -self._double_tap_period
self._press_time = monotonic()
self._prior_state = self.in_out.value
def action(self):
"""
Process pin input. This MUST be called frequently for debounce, etc.
to work, since interrupts are not available.
Returns:
None, TAP, DOUBLE_TAP, HOLD or RELEASE.
"""
new_state = self.in_out.value
if new_state != self._prior_state:
# Button state changed since last call
self._prior_state = new_state
if not new_state:
# Button initially pressed (TAP not returned until debounce)
self._press_time = monotonic()
else:
# Button initially released
if self._holding:
# Button released after hold
self._holding = False
return self.RELEASE
if (monotonic() - self._press_time) >= self._debounce_period:
# Button released after valid debounce time
if monotonic() - self._tap_time < self._double_tap_period:
# Followed another recent tap, reset double timer
self._tap_time = 0
return self.DOUBLE_TAP
# Else regular debounced release, maybe 1st tap, keep time
self._tap_time = monotonic()
else:
# Button is in same state as last call
if self._prior_state:
# Is not pressed
if (self._tap_time > 0 and
(monotonic() - self._tap_time) > self._double_tap_period):
# Enough time since last tap that it's not a double
self._tap_time = 0
return self.TAP
elif (not self._holding and
(monotonic() - self._press_time) >= self._hold_period):
# Is pressed, and has been for the holding period
self._holding = True
return self.HOLD
return None

View file

@ -1,217 +0,0 @@
"""
BMP-to-ulab-columns code, originally adapted from HalloWing Light Paintstick
but it's mutated through several generations since then. This version uses
ulab ndarrays (rather than bytearrays) to facilitate cool stuff.
"""
# pylint: disable=import-error
from os import listdir
from ulab import array, uint8
class BMPError(Exception):
"""Used for raising errors in the UBMP Class."""
pass
# pylint: disable=too-few-public-methods
class BMPSpec:
"""
Contains vitals of a UBMP's active BMP file.
Returned by the read_header() function.
"""
def __init__(self, width, height, image_offset, flip):
"""
BMPSpec constructor.
Arguments:
width (int) : BMP image width in pixels.
height (int) : BMP image height in pixels.
image_offset (int) : Offset from start of file to first byte of
pixel data.
flip (boolean) : True if image is stored bottom-to-top,
vs top-to-bottom.
"""
self.width = width
self.height = height
self.image_offset = image_offset
self.flip = flip
self.row_size = (width * 3 + 3) & ~3 # 32-bit line boundary
class UBMP:
"""
Handles conversion of BMP images to a list of ulab uint8 ndarrays
(representing columns) that can be processed/interpolated and/or
passed directly to the low-level neopixel_write() function.
Intended for light painting projects.
"""
def __init__(self, num_pixels, order='grb'):
"""
Constructor for UBMP Class.
Arguments:
num_pixels (int) : Number of pixels in LED strip, expected
to not change over the life of the object.
order (string) : LED pixel data color order; grb on most
strips, but sometimes others exist.
"""
self.num_pixels = num_pixels
self.file = None
self.bytes_per_pixel = len(order) # Handle RGB vs RGBW
order = order.lower()
self.red_index = order.find('r')
self.green_index = order.find('g')
self.blue_index = order.find('b')
# W, if present, will be ignored. Only RGB data from BMP is used.
def read_le(self, num_bytes):
"""
Little-endian read from active BMP file.
Arguments:
num_bytes (int) : Number of bytes to read from file and convert
to integer value, little-end (least
significant byte) first. Typically 2 or 4.
Returns:
Converted integer product.
"""
result = 0
for byte_index, byte in enumerate(self.file.read(num_bytes)):
result += byte << (byte_index * 8)
return result
def read_header(self):
"""
Read and validate BMP file heaader. Throws exception if file
attributes are incorrect (e.g. unsupported BMP variant).
Returns:
BMPSpec object containing size, offset, etc.
"""
if self.file.read(2) != b'BM': # Check signature
raise BMPError("Not BMP file")
self.file.read(8) # Read & ignore file size & creator bytes
image_offset = self.read_le(4) # Start of image data
self.file.read(4) # Read & ignore header size
width = self.read_le(4)
height = self.read_le(4)
# BMPs are traditionally stored bottom-to-top.
# If bmp_height is negative, image is in top-down order.
# This is not BMP canon but has been observed in the wild!
flip = True
if height < 0:
height = -height
flip = False
if self.read_le(2) != 1:
raise BMPError("Not single-plane")
if self.read_le(2) != 24: # bits per pixel
raise BMPError("Not 24-bit")
if self.read_le(2) != 0:
raise BMPError("Compressed file")
return BMPSpec(width, height, image_offset, flip)
def scandir(self, path):
"""
Scan a given path, looking for compatible BMP image files.
Arguments:
path (string) : Directory to search. If '', root path is used.
Returns:
List of compatible BMP filenames within path. Path is NOT
included in names. Subdirectories, non-BMP files and unsupported
BMP formats (e.g. compressed or paletted) are skipped.
List will be alphabetically sorted.
"""
valid_list = []
try:
full_list = listdir(path)
except OSError:
return valid_list
for entry in full_list:
try:
with open(path + '/' + entry, "rb") as self.file:
self.read_header()
valid_list.append(entry)
except (OSError, BMPError):
continue
valid_list.sort() # Alphabetize
return valid_list
def load(self, filename, callback=None):
"""
Load 24-bit uncompressed BMP file, returning as a list of ulab uint8
ndarrays that can be processed or passed directly to the low-level
neopixel_write() function. If BMP image is taller than length of
LED strip, image will be cropped. If shorter, will be displayed at
end (not start) of strip. It is recommended to call gc.collect()
after this function for smoothest playback.
Arguments:
filename (string) : Full path and filename of BMP image.
callback (func) : Callback function for displaying load
progress, will be passed a float ranging
from 0.0 to 1.0.
Returns
List of ulab uint8 ndarrays that can be further processed or
sequentially passed to neopixel_write().
"""
try:
print("Loading", filename)
with open(filename, "rb") as self.file:
#print("File opened")
bmp = self.read_header()
#print("WxH: (%d,%d)" % (bmp.width, bmp.height))
#print("Image format OK, reading data...")
# Constrain rows loaded to pixel strip length
clipped_height = min(bmp.height, self.num_pixels)
# Allocate per-column pixel buffers, sized for LED strip:
zerocol = bytearray(self.num_pixels * self.bytes_per_pixel)
columns = [array(zerocol, dtype=uint8)
for _ in range(bmp.width)]
# Image is displayed at END (not start) of NeoPixel strip,
# this index works incrementally backward in column buffers...
idx = (self.num_pixels - 1) * self.bytes_per_pixel
for row in range(clipped_height): # For each scanline...
# Seek to start of scanline
if bmp.flip: # Bottom-to-top order (normal BMP)
self.file.seek(bmp.image_offset +
(bmp.height - 1 - row) * bmp.row_size)
else: # BMP is stored top-to-bottom
self.file.seek(bmp.image_offset + row * bmp.row_size)
for column in columns: # For each pixel of scanline...
# BMP files use BGR color order
bgr = self.file.read(3) # Blue, green, red
# Rearrange into NeoPixel strip's color order,
# while handling brightness & gamma correction:
column[idx + self.blue_index] = bgr[0]
column[idx + self.green_index] = bgr[1]
column[idx + self.red_index] = bgr[2]
idx -= self.bytes_per_pixel # Advance (back) one pixel
if callback:
callback((row + 1) / clipped_height)
#print("Loaded OK!")
return columns
except OSError as err:
if err.args[0] == 28:
raise OSError("OS Error 28 0.25")
else:
raise OSError("OS Error 0.5")
except BMPError as err:
print("Failed to parse BMP: " + err.args[0])