Adafruit_Learning_System_Gu.../CLUE_Light_Painter/bmp2led.py
2022-02-18 17:07:25 -05:00

401 lines
19 KiB
Python
Executable file

# SPDX-FileCopyrightText: 2020 Phillip Burgess for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
BMP-to-DotStar-ready-bytearrays.
"""
# pylint: disable=import-error
import os
import math
import ulab
BUFFER_ROWS = 32
class BMPError(Exception):
"""Used for raising errors in the BMP2LED Class."""
pass
# pylint: disable=too-few-public-methods
class BMPSpecs:
"""
Contains vitals of a BMP2LED's active BMP file.
Returned by the read_header() function.
"""
def __init__(self, width, height, image_offset, flip):
"""
BMPSpecs 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 BMP2LED:
"""
Handles conversion of BMP images to a binary file of DotStar-ready
rows that can be read and passed directly to the SPI write() function.
Intended for light painting projects.
"""
def __init__(self, num_pixels, order='brg', gamma=2.4):
"""
Constructor for BMP2LED Class. Arguments are values that are not
expected to change over the life of the object.
Arguments:
num_pixels (int) : Number of pixels in DotStar strip.
order (string) : DotStar data color order. Optional, default
is 'brg', used on most strips.
gamma (float) : Optional gamma-correction constant, for
more perceptually-linear output.
Optional; 2.4 if unspecified.
"""
order = order.lower()
self.red_index = order.find('r')
self.green_index = order.find('g')
self.blue_index = order.find('b')
self.num_pixels = num_pixels
self.gamma = gamma
self.bmp_file = None
self.bmp_specs = None
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.bmp_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:
BMPSpecs object containing size, offset, etc.
"""
if self.bmp_file.read(2) != b'BM': # Check signature
raise BMPError("Not BMP file")
self.bmp_file.read(8) # Read & ignore file size & creator bytes
image_offset = self.read_le(4) # Start of image data
self.bmp_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 BMPSpecs(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.
"""
full_list = os.listdir(path)
valid_list = []
for entry in full_list:
try:
with open(path + '/' + entry, 'rb') as self.bmp_file:
self.read_header()
valid_list.append(entry)
except (OSError, BMPError):
continue
valid_list.sort() # Alphabetize
return valid_list
def read_row(self, row, dest):
"""
Read one row of pixels from BMP file, clipped to minimum of BMP
image width or LED strip length.
Arguments:
row (int) : Index of row to read (0 to (img height-1)).
dest (uint8 ndarray) : Destination buffer. After reading, buffer
contains pixel data in BMP-native order
(B,G,R per pixel), no need to reorder to
DotStar order until later.
"""
# 'flip' logic is intentionally backwards from typical BMP loader,
# this makes BMP image prep an easy 90 degree CCW rotation.
if not self.bmp_specs.flip:
row = self.bmp_specs.height - 1 - row
self.bmp_file.seek(self.bmp_specs.image_offset +
row * self.bmp_specs.row_size)
self.bmp_file.readinto(dest)
# pylint: disable=too-many-arguments, too-many-locals
# pylint: disable=too-many-branches, too-many-statements
def process(self, input_filename, output_filename, rows,
brightness=1.0, loop=False, callback=None):
"""
Process a 24-bit uncompressed BMP file into a series of
DotStar-ready rows of bytes (including header and footer) written
to a binary file. The input image is stretched to a specified
number of rows, applying linear interpolation and error diffusion
dithering along the way. If BMP rows are narrower than LED strip
length, image be displayed at start of strip. If BMP rows are
wider, image will be cropped. Strongly recommended to call
gc.collect() after this function for smoothest playback.
Arguments:
input_filename (string) : Full path and filename of BMP image.
output_filename (string) : Full path and filename of binary
output file (DotStar-ready rows).
EXISTING FILE WILL BE RUDELY AND
IMMEDIATELY DELETED (and contents
likely replaced), even if function
fails to finish.
rows (int) : Number of rows to write to output
file; image will be stretched.
Actual number of rows may be less
than this depending on storage space.
brightness (float) : Overall brightness adjustment, from 0.0
(off) to 1.0 (maximum brightness),
or None to use default (1.0). Since
app is expected to call spi.write()
directly, the conventional DotStar
brightness setting is not observed,
only the value specified here.
loop (boolean) : If True, image playback to DotStar
strip will be repeated (end of list
needs to be represented differently
for looped vs. non-looped playback).
callback (func) : Callback function for displaying load
progress, will be passed a float
ranging from 0.0 (start) to 1.0 (end).
Returns: actual number of rows in output file (may be less than
number of rows requested, depending on storage space.
"""
# Allocate a working buffer for DotStar data, sized for LED strip.
# It's formed just like valid strip data (with header, per-pixel
# start markers and footer), with colors all '0' to start...these
# will be filled later.
dotstar_buffer = ulab.numpy.array([0] * 4 +
[255, 0, 0, 0] * self.num_pixels +
[255] * ((self.num_pixels + 15) // 16),
dtype=ulab.numpy.uint8)
dotstar_row_size = len(dotstar_buffer)
# Output rows are held in RAM and periodically written,
# marginally faster than writing each row separately.
output_buffer = bytearray(BUFFER_ROWS * dotstar_row_size)
output_position = 0
# Delete old temporary file, if any
try:
os.remove(output_filename)
except OSError:
pass
# Determine free space on drive
stats = os.statvfs('/')
bytes_free = stats[0] * stats[4] # block size, free blocks
if not loop: # If not looping, leave space
bytes_free -= dotstar_row_size # for 'off' LED data at end.
# Clip the maximum number of output rows based on free space and
# the size (in bytes) of each DotStar row.
rows = min(rows, bytes_free // dotstar_row_size)
try:
with open(input_filename, 'rb') as self.bmp_file:
#print("File opened")
self.bmp_specs = self.read_header()
#print("WxH: (%d,%d)" % (self.bmp_specs.width,
# self.bmp_specs.height))
#print("Image format OK, reading data...")
# Constrain bytes-to-read to pixel strip length
clipped_width = min(self.bmp_specs.width, self.num_pixels)
row_bytes = 3 * clipped_width
# Each output row is interpolated from two BMP rows,
# we'll call them 'a' and 'b' here.
row_a_data = ulab.numpy.zeros(row_bytes, dtype=ulab.numpy.uint8)
row_b_data = ulab.numpy.zeros(row_bytes, dtype=ulab.numpy.uint8)
prev_row_a_index, prev_row_b_index = None, None
with open(output_filename, 'wb') as led_file:
# To avoid continually appending to output file (a slow
# operation), seek to where the end of the file would
# be, write a nonsense byte there, then seek back to
# the beginning. Significant improvement!
led_file.seek((dotstar_row_size * rows) - 1)
led_file.write(b'\0')
led_file.seek(0)
err = 0
for row in range(rows): # For each output row...
# Scale position into pixel space...
if loop: # 0 to <image height
position = self.bmp_specs.height * row / rows
else: # 0 to last row.0
position = (row / (rows - 1) *
(self.bmp_specs.height - 1))
# Separate absolute position into several values:
# integer 'a' and 'b' row indices, floating 'a' and
# 'b' weights (0.0 to 1.0) for interpolation.
row_b_weight, row_a_index = math.modf(position)
row_a_index = min(int(row_a_index),
self.bmp_specs.height - 1)
row_b_index = (row_a_index + 1) % self.bmp_specs.height
row_a_weight = 1.0 - row_b_weight
# New data ONLY needs reading if row index changed
# (else do another interp/dither with existing data)
if row_a_index != prev_row_a_index:
# If we've advanced exactly one row, reassign
# old 'b' data to 'a' row (swap, so buffers
# remain distinct), else read new 'a'.
if row_a_index == prev_row_b_index:
row_a_data, row_b_data = row_b_data, row_a_data
else:
self.read_row(row_a_index, row_a_data)
# Read new 'b' data on any row change
self.read_row(row_b_index, row_b_data)
prev_row_a_index = row_a_index
prev_row_b_index = row_b_index
# Pixel values are stored as bytes from 0-255.
# Gamma correction requires floats from 0.0 to 1.0.
# So there's a scaling operation involved, BUT, as
# configurable 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 row
# interpolation weights from 0.0-1.0 to
# 0.0-brightness/255.
row_a_weight *= brightness / 255
row_b_weight *= 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 8-bit range. Scaling to 254.999
# (not 255) lets us avoid a subsequent clip check.
want = ((((row_a_data * row_a_weight) +
(row_b_data * row_b_weight)) **
self.gamma) * 254.999)
# 'got' will be an ndarray of the values that get
# issued to the LED strip, formed through several
# operations. First, the 'want' values are quantized
# to uint8's -- so these will always be slightly
# dimmer (v. occasionally equal) to the 'want' vals.
got = ulab.numpy.array(want, dtype=ulab.numpy.uint8)
# Note: naive 'foo = foo + bar' syntax used in this
# next section is intentional. ndarrays don't seem
# to always play well with '+=' syntax.
# The difference between what we want and what we
# got will be an ndarray of values from 0.0 to <1.0.
# This is accumulated into the error ndarray to be
# applied to this and subsequent rows.
err = err + want - got
# Accumulated error vals will all now be 0.0 to <2.0.
# Quantizing err into a new uint8 ndarray, all values
# will be 0 or 1.
err_bits = ulab.numpy.array(err, dtype=ulab.numpy.uint8)
# Add the 1's back into 'got', increasing the
# brightness of certain pixels by 1. Because the max
# value in 'got' is 254 (not 255), no clipping need
# be performed, everything still fits in uint8.
got = got + err_bits
# Subtract those applied 1's from the error array,
# leaving residue in the range 0.0 to <1.0 which
# will be used on subsequent rows.
err = err - err_bits
# Reorder data from BGR to DotStar color order,
# allowing for header and start-of-pixel markers
# in the DotStar data.
dotstar_buffer[5 + self.blue_index:
5 + 4 * clipped_width:4] = got[0::3]
dotstar_buffer[5 + self.green_index:
5 + 4 * clipped_width:4] = got[1::3]
dotstar_buffer[5 + self.red_index:
5 + 4 * clipped_width:4] = got[2::3]
output_buffer[output_position:output_position +
dotstar_row_size] = memoryview(
dotstar_buffer)
# Add converted data to output buffer.
# Periodically write when full.
output_position += dotstar_row_size
if output_position >= len(output_buffer):
led_file.write(output_buffer)
if callback:
callback(row / (rows - 1))
output_position = 0
# Write any remaining buffered data
if output_position:
led_file.write(output_buffer[:output_position])
if callback:
callback(1.0)
# If not looping, add an 'all off' row of LED data
# at end to ensure last row timing is consistent.
if not loop:
rows += 1
led_file.write(bytearray([0] * 4 +
[255, 0, 0, 0] *
self.num_pixels +
[255] *
((self.num_pixels + 15) //
16)))
#print("Loaded OK!")
return rows
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])