Skip to content

Commit 619cf2c

Browse files
Basically working, with some compromises
1 parent 80a1196 commit 619cf2c

5 files changed

Lines changed: 58 additions & 51 deletions

File tree

CLUE_Light_Painter/bmp2led.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
BMP-to-DotStar-ready-bytearrays.
33
"""
44

5+
# pylint: disable=import-error
56
import os
67
import math
78
import ulab
@@ -42,7 +43,7 @@ class BMP2LED:
4243
Intended for light painting projects.
4344
"""
4445

45-
def __init__(self, num_pixels, order='brg', gamma=2.6):
46+
def __init__(self, num_pixels, order='brg', gamma=2.4):
4647
"""
4748
Constructor for BMP2LED Class. Arguments are values that are not
4849
expected to change over the life of the object.
@@ -52,7 +53,7 @@ def __init__(self, num_pixels, order='brg', gamma=2.6):
5253
is 'brg', used on most strips.
5354
gamma (float) : Optional gamma-correction constant, for
5455
more perceptually-linear output.
55-
Optional; 2.6 if unspecified.
56+
Optional; 2.4 if unspecified.
5657
"""
5758
order = order.lower()
5859
self.red_index = order.find('r')
@@ -158,8 +159,10 @@ def read_row(self, row, num_bytes):
158159
return ulab.array(self.bmp_file.read(num_bytes), dtype=ulab.uint8)
159160

160161

162+
# pylint: disable=too-many-arguments, too-many-locals
163+
# pylint: disable=too-many-branches, too-many-statements
161164
def process(self, input_filename, output_filename, rows,
162-
brightness=None, loop=False, callback=None):
165+
brightness=None, loop=False, callback=None):
163166
"""
164167
Process a 24-bit uncompressed BMP file into a series of
165168
DotStar-ready rows of bytes (including header and footer) written
@@ -216,7 +219,7 @@ def process(self, input_filename, output_filename, rows,
216219

217220
# Determine free space on drive
218221
stats = os.statvfs('/')
219-
bytes_free = stats[0] * stats[4] # block size, free blocks
222+
bytes_free = stats[0] * stats[4] # block size, free blocks
220223
if not loop: # If not looping, leave space
221224
bytes_free -= dotstar_row_size # for 'off' LED data at end.
222225
# Clip the maximum number of output rows based on free space and
@@ -249,9 +252,9 @@ def process(self, input_filename, output_filename, rows,
249252
if callback:
250253
callback(position)
251254
# Scale position into pixel space...
252-
if loop: # 0 to image height
253-
position *= self.bmp_specs.height
254-
else: # 0 to last row
255+
if loop: # 0 to <image height
256+
position = self.bmp_specs.height * row / rows
257+
else: # 0 to last row.0
255258
position *= (self.bmp_specs.height - 1)
256259

257260
# Separate absolute position into several values:
@@ -320,10 +323,9 @@ def process(self, input_filename, output_filename, rows,
320323
# Make note of the difference...the 'error term'...
321324
# between what we ideally wanted (float) and what we
322325
# actually got (dithered, clipped and quantized).
323-
# This will get used on the next pass through the
324-
# loop. Don't keep 100% of the value, or image
325-
# 'shimmers' too much...dial back slightly.
326-
err = (want - got) * 0.95
326+
# This gets used on the next pass through the loop.
327+
err = err + ((want - got) * 0.5)
328+
# ('+=' syntax doesn't work on ndarrays)
327329

328330
# Reorder data from BGR to DotStar color order,
329331
# allowing for header and start-of-pixel markers
15.2 KB
Binary file not shown.
15.2 KB
Binary file not shown.
-15.2 KB
Binary file not shown.

CLUE_Light_Painter/code.py

Lines changed: 45 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,11 @@
1010
"""
1111

1212
# pylint: disable=import-error
13-
import os
1413
import gc
14+
from time import monotonic, sleep
1515
import board
1616
import busio
1717
import displayio
18-
from time import monotonic, sleep
1918
from digitalio import DigitalInOut, Direction
2019
from bmp2led import BMP2LED, BMPError
2120
from neopixel_write import neopixel_write
@@ -34,9 +33,11 @@
3433
PATH = '/bmps-72px' # Folder containing BMP images (or '' for root path)
3534
TEMPFILE = '/led.dat' # Working file for LED data (will be clobbered!)
3635
FLIP_SCREEN = False # If True, turn CLUE screen & buttons upside-down
37-
GAMMA = 2.6 # Correction factor for perceptually linear brightness
36+
GAMMA = 2.4 # Correction factor for perceptually linear brightness
37+
38+
# Temporary line during development, delete before use:
39+
PIXEL_ORDER = 'gbr' # Old DotStar strip with different color order
3840

39-
PIXEL_ORDER = 'gbr'
4041

4142
def centered_label(text, y_pos, scale):
4243
"""
@@ -73,11 +74,8 @@ def __init__(self, flip, path, tempfile, num_pixels, pixel_order,
7374
file for LED data (will be clobbered).
7475
num_pixels (int) : LED strip length.
7576
pixel_order (string) : LED data order, e.g. 'grb'.
76-
pixel_pins (tuple) : Board pin(s) for LED data output. If a
77-
single value (int), a NeoPixel strip is
78-
being used. If two values (tuple or
79-
list), it's a DotStar strip (pins are
80-
data and clock of an SPI port).
77+
pixel_pins (tuple) : Board pin for LED data output (SPI data
78+
and clock pins respectively).
8179
gamma (float) : Correction for perceptual linearity.
8280
"""
8381
self.bmp2led = BMP2LED(num_pixels, pixel_order, gamma)
@@ -92,8 +90,7 @@ def __init__(self, flip, path, tempfile, num_pixels, pixel_order,
9290
self.spi.configure(baudrate=8000000)
9391

9492
# Determine filesystem-to-LEDs throughput (also clears LED strip)
95-
# self.rows_per_second, self.row_size = self.benchmark()
96-
self.rows_per_second, self.row_size = 1000, 500
93+
self.rows_per_second, self.row_size = self.benchmark()
9794

9895
# Configure hardware initial state
9996
self.button_left = RichButton(board.BUTTON_A)
@@ -134,25 +131,32 @@ def benchmark(self):
134131
(including DotStar header and footer) (int).
135132
"""
136133
# Generate a small temporary file equal to one full LED row,
137-
# all set 'off' (bonus, this turns off LED strip on startup).
134+
# all set 'off'.
135+
row_data = bytearray([0] * 4 +
136+
[255, 0, 0, 0] * self.bmp2led.num_pixels +
137+
[255] * ((self.bmp2led.num_pixels + 15) //
138+
16))
139+
row_size = len(row_data)
138140
with open(self.tempfile, 'wb') as file:
139-
row_data = bytearray([0] * 4 +
140-
[255, 0, 0, 0] * self.bmp2led.num_pixels +
141-
[255] * ((self.bmp2led.num_pixels + 15) //
142-
16))
143141
file.write(row_data)
144-
row_size = len(row_data)
145142

146143
# For a period of 1 second, repeatedly seek to start of file,
147144
# read row of data and write to LED strip as fast as possible.
148145
# Not super precise, but good-enough guess of light painting speed.
146+
# (Bonus, this will turn off LED strip on startup).
149147
rows = 0
150148
with open(self.tempfile, 'rb') as file:
151-
gc.collect()
152149
start_time = monotonic()
153150
while monotonic() - start_time < 1.0:
154151
file.seek(0)
155-
self.spi.write(file.read(row_size))
152+
# using readinto() instead of read() reduces the amount
153+
# of work the garbage collector needs to do each row.
154+
file.readinto(row_data)
155+
self.spi.write(row_data)
156+
# Garbage collection is done on EVERY row...even though
157+
# this slows down painting a LOT, it keeps the timing more
158+
# consistent (else there would be conspicuous glitches).
159+
gc.collect()
156160
rows += 1
157161

158162
return rows, row_size
@@ -180,8 +184,8 @@ def load_progress(self, amount):
180184
def load_image(self):
181185
"""
182186
Load BMP from image list, determined by variable self.image_num
183-
(not a passed argument). Data is converted and placed in variable
184-
self.columns[].
187+
(not a passed argument). Data is converted and placed in
188+
self.tempfile.
185189
"""
186190
# Minimal progress display while image is loaded.
187191
group = displayio.Group()
@@ -191,8 +195,8 @@ def load_image(self):
191195
group.append(self.rect)
192196
board.DISPLAY.show(group)
193197

194-
#duration = 5.0 - self.speed * 4.5
195-
duration = 3.0 - self.speed * 2.75
198+
# Playback time is about 1/4 to 5 seconds, non linearly spaced
199+
duration = 0.25 + 4.75 * ((1.0 - self.speed) ** 2.5)
196200
rows = duration * self.rows_per_second
197201
try:
198202
self.num_rows = self.bmp2led.process(self.path + '/' +
@@ -214,15 +218,15 @@ def load_image(self):
214218
def paint(self):
215219
"""
216220
Paint mode. Watch for button taps to start/stop image playback,
217-
or button hold to switch to config mode. During playback, do all
218-
the nifty image processing.
221+
or button hold to switch to config mode.
219222
"""
220223

221224
board.DISPLAY.brightness = 0 # Screen backlight OFF
222225
painting = False
223226

224227
with open(self.tempfile, 'rb') as file:
225-
gc.collect() # Helps make playback a little smoother
228+
led_buffer = bytearray(self.row_size)
229+
gc.collect()
226230

227231
while True:
228232
action_set = {self.button_left.action(),
@@ -238,7 +242,14 @@ def paint(self):
238242

239243
if painting:
240244
file.seek(row * self.row_size)
241-
self.spi.write(file.read(self.row_size))
245+
# using readinto() instead of read() reduces the amount
246+
# of work the garbage collector needs to do each row.
247+
file.readinto(led_buffer)
248+
self.spi.write(led_buffer)
249+
# Garbage collection is done on EVERY row...even though
250+
# this slows down painting a LOT, it keeps the timing more
251+
# consistent (else there would be conspicuous glitches).
252+
gc.collect()
242253
row += 1
243254
if row >= self.num_rows:
244255
if self.loop:
@@ -253,7 +264,7 @@ def paint(self):
253264
# function. This way definitely generates less pylint gas pains.
254265
# Also, creating and destroying elements (rather than creating
255266
# them all up-front and showing or hiding elements as needed)
256-
# tends to use less RAM, leaving more for image.
267+
# tends to use less RAM.
257268

258269
def make_ui_group(self, main_config, config_label, rect_val=None):
259270
"""
@@ -335,7 +346,6 @@ def config_select(self, first_run=False):
335346
prev_mode = self.config_mode
336347

337348
# Before exiting to paint mode, check if new image needs loaded
338-
# DO IMAGE CONVERSION HERE!
339349
if reload_image:
340350
self.load_image()
341351

@@ -348,7 +358,8 @@ def config_image(self):
348358
be reloaded, second indicates if returning to paint mode vs
349359
more config.
350360
"""
351-
group = self.make_ui_group(False, self.images[self.image_num])
361+
group = self.make_ui_group(False,
362+
self.images[self.image_num].split('.')[0])
352363
orig_image, prev_image = self.image_num, self.image_num
353364

354365
while True:
@@ -365,8 +376,8 @@ def config_image(self):
365376

366377
if self.image_num is not prev_image:
367378
group.pop()
368-
group.append(centered_label(self.images[self.image_num],
369-
40, 3))
379+
group.append(centered_label(
380+
self.images[self.image_num].split('.')[0], 40, 3))
370381
prev_image = self.image_num
371382

372383

@@ -450,7 +461,8 @@ def config_brightness(self):
450461

451462
def run(self):
452463
"""
453-
Application loop just consists of alternating paint and
464+
Post-init application loop. After a one-time visit to image select
465+
(and possibly other config), just consists of alternating paint and
454466
config modes. Each function has its own condition for return
455467
(switching to the opposite mode). Repeat forever.
456468
"""
@@ -465,12 +477,5 @@ def run(self):
465477
self.config_select()
466478

467479

468-
# Note to future self: make program start in image-select mode,
469-
# then when that returns, go into settings or paint depending
470-
# on return status.
471-
# # Load first image in list
472-
# self.load_image()
473-
474-
475480
ClueLightPainter(FLIP_SCREEN, PATH, TEMPFILE,
476481
NUM_PIXELS, PIXEL_ORDER, PIXEL_PINS, GAMMA).run()

0 commit comments

Comments
 (0)