Skip to content

Commit a527f9a

Browse files
WIP, very dismantled and broken
1 parent 9a9a801 commit a527f9a

6 files changed

Lines changed: 1163 additions & 46 deletions

File tree

CLUE_Light_Painter/bmp2led.py

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
"""
2+
BMP-to-DotStar-ready-bytearrays.
3+
"""
4+
5+
import os
6+
7+
class BMPError(Exception):
8+
"""Used for raising errors in the BMP2LED Class."""
9+
pass
10+
11+
12+
# pylint: disable=too-few-public-methods
13+
class BMPSpecs:
14+
"""
15+
Contains vitals of a BMP2LED's active BMP file.
16+
Returned by the read_header() function.
17+
"""
18+
def __init__(self, width, height, image_offset, flip):
19+
"""
20+
BMPSpecs constructor.
21+
Arguments:
22+
width (int) : BMP image width in pixels.
23+
height (int) : BMP image height in pixels.
24+
image_offset (int) : Offset from start of file to first byte of
25+
pixel data.
26+
flip (boolean) : True if image is stored bottom-to-top,
27+
vs top-to-bottom.
28+
"""
29+
self.width = width
30+
self.height = height
31+
self.image_offset = image_offset
32+
self.flip = flip
33+
self.row_size = (width * 3 + 3) & ~3 # 32-bit line boundary
34+
35+
36+
class BMP2LED:
37+
"""
38+
Handles conversion of BMP images to a binary file of DotStar-ready
39+
rows that can be read and passed directly to the SPI write() function.
40+
Intended for light painting projects.
41+
"""
42+
43+
def __init__(self, num_pixels, order='brg', gamma=2.6):
44+
"""
45+
Constructor for BMP2LED Class. Arguments are values that are not
46+
expected to change over the life of the object.
47+
Arguments:
48+
num_pixels (int) : Number of pixels in DotStar strip.
49+
order (string) : DotStar data color order. Optional, default
50+
is 'brg', used on most strips.
51+
gamma (float) : Optional gamma-correction constant, for
52+
more perceptually-linear output.
53+
Optional; 2.6 if unspecified.
54+
"""
55+
order = order.lower()
56+
self.red_index = order.find('r')
57+
self.green_index = order.find('g')
58+
self.blue_index = order.find('b')
59+
self.num_pixels = num_pixels
60+
self.gamma = gamma
61+
self.bmpfile = None
62+
63+
64+
def read_le(self, num_bytes):
65+
"""
66+
Little-endian read from active BMP file.
67+
Arguments:
68+
num_bytes (int) : Number of bytes to read from file and convert
69+
to integer value, little-end (least
70+
significant byte) first. Typically 2 or 4.
71+
Returns:
72+
Converted integer product.
73+
"""
74+
result = 0
75+
for byte_index, byte in enumerate(self.bmpfile.read(num_bytes)):
76+
result += byte << (byte_index * 8)
77+
return result
78+
79+
80+
def read_header(self):
81+
"""
82+
Read and validate BMP file heaader. Throws exception if file
83+
attributes are incorrect (e.g. unsupported BMP variant).
84+
Returns:
85+
BMPSpecs object containing size, offset, etc.
86+
"""
87+
if self.bmpfile.read(2) != b'BM': # Check signature
88+
raise BMPError("Not BMP file")
89+
90+
self.bmpfile.read(8) # Read & ignore file size & creator bytes
91+
92+
image_offset = self.read_le(4) # Start of image data
93+
self.bmpfile.read(4) # Read & ignore header size
94+
width = self.read_le(4)
95+
height = self.read_le(4)
96+
# BMPs are traditionally stored bottom-to-top.
97+
# If bmp_height is negative, image is in top-down order.
98+
# This is not BMP canon but has been observed in the wild!
99+
flip = True
100+
if height < 0:
101+
height = -height
102+
flip = False
103+
104+
if self.read_le(2) != 1:
105+
raise BMPError("Not single-plane")
106+
if self.read_le(2) != 24: # bits per pixel
107+
raise BMPError("Not 24-bit")
108+
if self.read_le(2) != 0:
109+
raise BMPError("Compressed file")
110+
111+
return BMPSpecs(width, height, image_offset, flip)
112+
113+
114+
def scandir(self, path):
115+
"""
116+
Scan a given path, looking for compatible BMP image files.
117+
Arguments:
118+
path (string) : Directory to search. If '', root path is used.
119+
Returns:
120+
List of compatible BMP filenames within path. Path is NOT
121+
included in names. Subdirectories, non-BMP files and unsupported
122+
BMP formats (e.g. compressed or paletted) are skipped.
123+
List will be alphabetically sorted.
124+
"""
125+
full_list = os.listdir(path)
126+
valid_list = []
127+
for entry in full_list:
128+
try:
129+
with open(entry, "rb") as self.file:
130+
self.read_header()
131+
valid_list.append(entry)
132+
except (OSError, BMPError):
133+
continue
134+
135+
valid_list.sort() # Alphabetize
136+
return valid_list
137+
138+
139+
# old file will be overwritten.
140+
# gamma is stored in self,
141+
# brightness and loop will be passed in.
142+
# Oh...also need to pass in number of rows to stretch.
143+
# Number of LEDs is known from constructor. These are the items:
144+
# self.red_index = order.find('r')
145+
# self.green_index = order.find('g')
146+
# self.blue_index = order.find('b')
147+
# self.num_pixels = num_pixels
148+
# self.gamma = gamma
149+
# self.file = None
150+
# Delete existing tempfile before checking free space.
151+
152+
def process(self, input_filename, output_filename, rows,
153+
brightness=None, loop=False, callback=None):
154+
"""
155+
Process a 24-bit uncompressed BMP file into a series of
156+
DotStar-ready rows of bytes (including header and footer) written
157+
to a binary file. The input image is stretched to a specified
158+
number of rows, applying linear interpolation and error diffusion
159+
dithering along the way. If BMP rows are narrower than LED strip
160+
length, image be displayed at start of strip. If BMP rows are
161+
wider, image will be cropped. Strongly recommended to call
162+
gc.collect() after this function for smoothest playback.
163+
Arguments:
164+
input_filename (string) : Full path and filename of BMP image.
165+
output_filename (string) : Full path and filename of binary
166+
output file (DotStar-ready rows).
167+
EXISTING FILE WILL BE RUDELY AND
168+
IMMEDIATELY DELETED (and contents
169+
likely replaced), even if function
170+
fails to finish.
171+
rows (int) : Number of rows to write to output
172+
file; image will be stretched.
173+
Actual number of rows may be less
174+
than this depending on storage space.
175+
brightness (float) : Overall brightness adjustment, from 0.0
176+
(off) to 1.0 (maximum brightness),
177+
or None to use default (1.0). Since
178+
app is expected to call spi.write()
179+
directly, the conventional DotStar
180+
brightness setting is not observed,
181+
only the value specified here.
182+
loop (boolean) : If True, image playback to DotStar
183+
strip will be repeated (end of list
184+
needs to be represented differently
185+
for looped vs. non-looped playback).
186+
callback (func) : Callback function for displaying load
187+
progress, will be passed a float
188+
ranging from 0.0 (start) to 1.0 (end).
189+
Returns: actual number of rows in output file (may be less than
190+
number of rows requested, depending on storage space.
191+
"""
192+
193+
try:
194+
# Delete output file, then gauge available space on filesystem.
195+
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)
202+
203+
with open(input_filename, 'rb') as file_in:
204+
#print("File opened")
205+
206+
bmp = self.read_header()
207+
208+
#print("WxH: (%d,%d)" % (bmp.width, bmp.height))
209+
#print("Image format OK, reading data...")
210+
211+
# Constrain row width to pixel strip length
212+
clipped_width = min(bmp.width, self.num_pixels)
213+
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
218+
219+
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))
268+
269+
#print("Loaded OK!")
270+
return columns
271+
272+
except OSError as err:
273+
if err.args[0] == 28:
274+
raise OSError("OS Error 28 0.25")
275+
else:
276+
raise OSError("OS Error 0.5")
277+
except BMPError as err:
278+
print("Failed to parse BMP: " + err.args[0])

CLUE_Light_Painter/boot.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""
2+
Check for connection between pin and GND on hard boot (power-on or reset).
3+
If NO connection: storage is remounted as read/write so the light painter
4+
code can run (it requires temporary files), but code.py can't be edited.
5+
If connected: storage is left in read-only mode. Light painter code can't
6+
run but files are editable.
7+
"""
8+
9+
# pylint: disable=import-error
10+
import board
11+
import digitalio
12+
import storage
13+
14+
PIN = board.D0
15+
16+
IO = digitalio.DigitalInOut(PIN)
17+
IO.direction = digitalio.Direction.INPUT
18+
IO.pull = digitalio.Pull.UP
19+
20+
if IO.value: # No connection
21+
storage.remount('/', False) # Remount storage as read/write for painter

0 commit comments

Comments
 (0)