33"""
44
55import os
6+ import ulab
67
78class 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 :
0 commit comments