Skip to content

Commit acdd28e

Browse files
Still not done or working, but some work in progress
Interpolation etc. moved from paint() into BMP conversion code.
1 parent a527f9a commit acdd28e

8 files changed

Lines changed: 159 additions & 132 deletions

File tree

CLUE_Light_Painter/bmp2led.py

Lines changed: 146 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import os
6+
import ulab
67

78
class BMPError(Exception):
89
"""Used for raising errors in the BMP2LED Class."""
@@ -149,6 +150,24 @@ def scandir(self, path):
149150
# self.file = None
150151
# Delete existing tempfile before checking free space.
151152

153+
def read_row(self, row):
154+
"""
155+
Read one row of pixels from BMP file, clipped to minimum of BMP
156+
image width or LED strip length.
157+
Arguments:
158+
row (int): index of row to read (0 to (image height - 1))
159+
Returns: ulab ndarray (uint8 type) containing pixel data in
160+
BMP-native order (B,G,R per pixel), no need to reorder to DotStar
161+
order until later.
162+
"""
163+
# 'flip' logic is intentionally backwards from typical BMP loader,
164+
# this makes BMP image prep an easy 90 degree CCW rotation.
165+
if not bmp.flip:
166+
row = bmp.height - 1 - row
167+
self.file.seek(bmp.image_offset + row * bmp.row_size)
168+
return ulab.array(self.file.read(clipped_row_size), dtype=uint8)
169+
170+
152171
def process(self, input_filename, output_filename, rows,
153172
brightness=None, loop=False, callback=None):
154173
"""
@@ -190,16 +209,31 @@ def process(self, input_filename, output_filename, rows,
190209
number of rows requested, depending on storage space.
191210
"""
192211

212+
# Allocate a working buffer for DotStar data, sized for LED strip.
213+
# It's formed just like valid strip data (with header, per-pixel
214+
# start markers and footer), with colors all '0' to start...these
215+
# will be filled later.
216+
dotstar_buffer = bytearray([0] * 4 +
217+
[255, 0, 0, 0] * num_pixels +
218+
[255] * ((num_pixels + 15) // 16))
219+
dotstar_row_size = len(dotstar_buffer)
220+
221+
# Delete old temporary file, if any
193222
try:
194-
# Delete output file, then gauge available space on filesystem.
195223
os.remove(output_filename)
196-
stats = os.statvfs('/')
197-
bytes_free = stats[0] * stats[4] # block size, free blocks
198-
# Clip the maximum number of output rows based on free space and
199-
# the size (in bytes) of each DotStar row.
200-
dotstar_row_size = 4 + num_pixels * 4 + ((num_pixels + 15) // 16)
201-
rows = min(rows, bytes_free // dotstar_buffer_size)
224+
except OSError:
225+
pass
226+
227+
# Determine free space on drive
228+
stats = os.statvfs('/')
229+
bytes_free = stats[0] * stats[4] # block size, free blocks
230+
if not loop: # If not looping, leave space
231+
bytes_free -= dotstar_row_size # for 'off' LED data at end.
232+
# Clip the maximum number of output rows based on free space and
233+
# the size (in bytes) of each DotStar row.
234+
rows = min(rows, bytes_free // dotstar_row_size)
202235

236+
try:
203237
with open(input_filename, 'rb') as file_in:
204238
#print("File opened")
205239

@@ -211,63 +245,115 @@ def process(self, input_filename, output_filename, rows,
211245
# Constrain row width to pixel strip length
212246
clipped_width = min(bmp.width, self.num_pixels)
213247

214-
# Progress ratio along image ranges from 0.0 to 1.0 (if
215-
# looping playback) or a bit under 1.0 (one row relative to
216-
# full image height) if not looping.
217-
divisor = (bmp.height - 1) if loop else bmp.height
248+
# Each output row is interpolated from two BMP rows,
249+
# we'll call them 'a' and 'b' here.
250+
row_a_data, row_b_data = None, None
251+
prev_row_a_index, prev_row_b_index = None
218252

219253
with open(output_filename, 'wb') as file_out:
220-
for row in range(rows): # For each row...
221-
progress = row / divisor # 0.0 to 1.0-ish
222-
row_1 = int((bmp.height - 1) * progress)
223-
row_2 = (row_1 + 1) % bmp.height
224-
#read row_1 and row_2 data if needed
225-
226-
227-
228-
# Open input and output files
229-
# Don't use 'with' with two files, second won't close
230-
# Look at ExitStack(), or use two nested with's.
231-
232-
try:
233-
print("Loading", filename)
234-
with open(filename, "rb") as self.file:
235-
236-
237-
238-
# Image is displayed at END (not start) of NeoPixel strip,
239-
# this index works incrementally backward in column buffers...
240-
idx = (self.num_pixels - 1) * self.bytes_per_pixel
241-
for row in range(clipped_height): # For each scanline...
242-
# Seek to start of scanline
243-
if bmp.flip: # Bottom-to-top order (normal BMP)
244-
self.file.seek(bmp.image_offset +
245-
(bmp.height - 1 - row) * bmp.row_size)
246-
else: # BMP is stored top-to-bottom
247-
self.file.seek(bmp.image_offset + row * bmp.row_size)
248-
for column in columns: # For each pixel of scanline...
249-
# BMP files use BGR color order
250-
bgr = self.file.read(3) # Blue, green, red
251-
# Rearrange into NeoPixel strip's color order,
252-
# while handling brightness & gamma correction:
253-
column[idx + self.blue_index] = lut[bgr[0]]
254-
column[idx + self.green_index] = lut[bgr[1]]
255-
column[idx + self.red_index] = lut[bgr[2]]
256-
idx -= self.bytes_per_pixel # Advance (back) one pixel
257-
if callback:
258-
callback((row + 1) / clipped_height)
259-
260-
# Add one more column with no color data loaded. This is used
261-
# to turn the strip off at the end of the painting operation.
262-
# It's done this way (rather than checking for last column and
263-
# clearing LEDs in the painting code) so timing of the last
264-
# column is consistent and looks good for photos.
265-
if not loop:
266-
columns.append(bytearray(self.num_pixels *
267-
self.bytes_per_pixel))
254+
for row in range(rows): # For each output row...
255+
position = row / (rows - 1) # 0.0 to 1.0
256+
if callback:
257+
callback(position)
258+
# Scale position into pixel space...
259+
if self.loop: # 0 to image height
260+
position *= len(self.columns)
261+
else: # 0 to last row
262+
position *= (len(self.columns) - 1)
263+
264+
# Separate absolute position into several values:
265+
# integer 'a' and 'b' row indices, floating 'a' and
266+
# 'b' weights (0.0 to 1.0) for interpolation.
267+
row_b_weight, row_a_index = modf(position)
268+
row_a_index = int(row_a_index)
269+
row_b_index = (row_a_index + 1) % bmp.height
270+
row_a_weight = 1.0 - row_b_weight
271+
272+
# New data ONLY needs reading if row index changed
273+
# (else do another interp/dither with existing data)
274+
if row_a_index != prev_row_a_index:
275+
# If we've advanced exactly one row, reassign
276+
# old 'b' data to 'a' row, else read new 'a'.
277+
if row_a_index == prev_row_b_index:
278+
row_a_data = row_b_data
279+
else:
280+
row_a_data = self.read(row_a_index)
281+
# Read new 'b' data on any row change
282+
row_b_data = self.read(row_b_index)
283+
prev_row_a_index = row_a_index
284+
prev_row_b_index = row_b_index
285+
286+
# Pixel values are stored as bytes from 0-255.
287+
# Gamma correction requires floats from 0.0 to 1.0.
288+
# So there's a scaling operation involved, BUT, as
289+
# configurable brightness is also a thing, we can
290+
# work that into the same operation. Rather than
291+
# dividing pixels by 255, multiply by
292+
# brightness / 255. This reduces the two row
293+
# interpolation weights from 0.0-1.0 to
294+
# 0.0-brightness/255.
295+
row_a_weight *= brightness / 255
296+
row_b_weight *= brightness / 255
297+
298+
# 'want' is an ndarray of the idealized (as in,
299+
# floating-point) pixel values resulting from the
300+
# interpolation, with gamma correction applied and
301+
# scaled back up to the 0-255 range.
302+
want = ((row_a_data * row_a_weight +
303+
row_b_data * row_b_weight) **
304+
self.gamma * 255.001)
305+
306+
# 'got' will be an ndarray of the values that get
307+
# issued to the LED strip, formed through several
308+
# operations. First, an 'error term' is added to
309+
# each pixel, representing how 'wrong' the prior
310+
# output was. This is used for error diffusion
311+
# dithering. 'got' is floating-point at this stage.
312+
got = ulab.array(want + err)
313+
# The error term may push some pixel values outside
314+
# the required 0-255 range, so clip the result (aka
315+
# 'saturate'). (Note to future self: requested a
316+
# clip() function in ulab, should be available for
317+
# use soon, would replace these two Python ops).
318+
got[got < 0] = 0
319+
got[got > 255] = 255
320+
# ulab.compare.clip(got, 0, 255)
321+
# Now quantize the floating-point 'got' to uint8
322+
# type. This represents the actual final byte values
323+
# that will be issued to the LED strip.
324+
got = ulab.array(got, dtype=ulab.uint8)
325+
# Make note of the difference...the 'error term'...
326+
# between what we ideally wanted (float) and what we
327+
# actually got (dithered, clipped and quantized).
328+
# This will get used on the next pass through the
329+
# loop. Don't keep 100% of the value, or image
330+
# 'shimmers' too much...dial back slightly.
331+
err = (want - got) * 0.95
332+
333+
# Reorder data from BGR to DotStar color order,
334+
# allowing for header and start-of-pixel markers
335+
# in the DotStar data.
336+
for column in range(clipped_width):
337+
bmp_pos = x * 3
338+
dotstar_pos = 5 + x * 4
339+
bgr = data[bmp_pos:bmp_pos + 3]
340+
dotstar_buffer[dotstar_pos + blue_index] = bgr[0]
341+
dotstar_buffer[dotstar_pos + green_index] = bgr[1]
342+
dotstar_buffer[dotstar_pos + red_index] = bgr[2]
343+
344+
file_out.write(dotstar_buffer)
345+
346+
# If not looping, add an 'all off' row of LED data
347+
# at end to ensure last row timing is consistent.
348+
if not loop:
349+
rows += 1
350+
file_out.write(bytearray([0] * 4 +
351+
[255, 0, 0, 0] * num_pixels +
352+
[255] * ((num_pixels + 15) //
353+
16)))
268354

269355
#print("Loaded OK!")
270-
return columns
356+
return rows
271357

272358
except OSError as err:
273359
if err.args[0] == 28:
-2.75 KB
Binary file not shown.
-5.33 KB
Binary file not shown.
-4.98 KB
Binary file not shown.
0 Bytes
Binary file not shown.
0 Bytes
Binary file not shown.
-144 Bytes
Binary file not shown.

CLUE_Light_Painter/code.py

Lines changed: 13 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""
22
Light painting project for Adafruit CLUE using DotStar LED strip.
3-
Images should be in 24-bit BMP format, with height matching the length
3+
Images should be in 24-bit BMP format, with width matching the length
44
of the LED strip. The ulab module is used to assist with interpolation
55
and dithering, displayio for a minimal user interface.
66
"""
@@ -213,7 +213,9 @@ def paint(self):
213213

214214
board.DISPLAY.brightness = 0 # Screen backlight OFF
215215
painting = False
216-
duration = 5.0 - self.speed * 4.5 # 0.5 to 5 seconds
216+
217+
row_size = 4 + num_leds * 4 + ((num_leds + 15) // 16)
218+
# num_rows = was determined during conversion
217219

218220
gc.collect() # Helps make playback a little smoother
219221

@@ -224,81 +226,20 @@ def paint(self):
224226
if painting: # If currently painting
225227
self.clear_strip() # Turn LEDs OFF
226228
else:
227-
start_time = monotonic()
228-
err = 0 # Clear 'error term' used for dithering
229+
row = 0 # Start at beginning of file
229230
painting = not painting # Toggle paint mode on/off
230231
elif RichButton.HOLD in action_set:
231232
return # Exit painting, enter config mode
232233

233234
if painting:
234-
elapsed = monotonic() - start_time
235-
if self.loop:
236-
elapsed %= duration
237-
elif elapsed > duration:
238-
self.clear_strip()
239-
painting = False
240-
continue
241-
242-
# Current absolute position along image, as floating-point
243-
# value from 0.0 (first column) to last column.
244-
position = elapsed / duration # 0.0 to 1.0
245-
if self.loop:
246-
position *= len(self.columns) # 0 to image width
247-
else:
248-
position *= (len(self.columns) - 1) # 0 to last column
249-
# Separate the absolute position into three values:
250-
# the relative 'weight' of the subsequent image column
251-
# when interpolating between two, the integer index of the
252-
# first column and integer index of second column.
253-
weight_2, column_1 = modf(position)
254-
column_1 = int(column_1)
255-
column_2 = (column_1 + 1) % len(self.columns)
256-
weight_1 = 1.0 - weight_2
257-
258-
# Pixel values are stored as bytes from 0-255.
259-
# Gamma correction requires floats from 0.0 to 1.0.
260-
# So there's going to be a scaling operation involved,
261-
# BUT, as configurable LED brightness is also a thing,
262-
# we can work that into the same operation. Rather than
263-
# dividing pixels by 255, multiply by brightness / 255.
264-
# This reduces the two column interpolation weightings
265-
# from 0.0-1.0 to 0.0-brightness/255.
266-
weight_1 *= self.brightness / 255
267-
weight_2 *= self.brightness / 255
268-
269-
# 'want' is an ndarray of the idealized (as in,
270-
# floating-point) pixel values resulting from the
271-
# interpolation, with gamma correction applied and
272-
# scaled back up to the 0-255 range.
273-
want = ((self.columns[column_1] * weight_1 +
274-
self.columns[column_2] * weight_2) **
275-
self.gamma * 255.001)
276-
# 'got' will be an ndarray of the values that get issued to
277-
# the LED strip, formed through several operations. First,
278-
# an 'error term' is added to each pixel, representing how
279-
# 'wrong' the prior output was. This is used for error
280-
# diffusion dithering. 'got' is floating-point at this stage.
281-
got = ulab.array(want + err)
282-
# The error term may push some pixel values outside the
283-
# required 0-255 range, so clip the result (aka 'saturate').
284-
# (Note to future self: requested a clip() function in ulab,
285-
# if that gets added in some future release, that can be
286-
# used here instead of these two Python ops.)
287-
got[got < 0] = 0
288-
got[got > 255] = 255
289-
# Now quantize the floating-point 'got' to uint8 type.
290-
# This represents the actual final byte values that will
291-
# be issued to the LED strip.
292-
got = ulab.array(got, dtype=ulab.uint8)
293-
# Make note of the difference...the 'error term'...between
294-
# what we ideally wanted (float) and what we actually got
295-
# (dithered, clipped and quantized). This will get used on
296-
# the next pass through the loop. Don't keep 100% of the
297-
# value, or image 'shimmers' too much...dial back slightly.
298-
err = (want - got) * 0.9
299-
300-
# Issue the resulting uint8 'got' data to the LED strip.
301-
self.write_func(self.neopixel_pin, got)
235+
file.seek(row * row_size)
236+
self.spi.write(file.read(row_size))
237+
row += 1
238+
if row >= num_rows:
239+
if self.loop:
240+
row = 0
241+
else:
242+
painting = False
302243

303244

304245
# Each config screen is broken out into its own function...

0 commit comments

Comments
 (0)