/ CLUE_Light_Painter / bmp2led.py
bmp2led.py
  1  # SPDX-FileCopyrightText: 2020 Phillip Burgess for Adafruit Industries
  2  #
  3  # SPDX-License-Identifier: MIT
  4  
  5  """
  6  BMP-to-DotStar-ready-bytearrays.
  7  """
  8  
  9  # pylint: disable=import-error
 10  import os
 11  import math
 12  import ulab
 13  
 14  BUFFER_ROWS = 32
 15  
 16  class BMPError(Exception):
 17      """Used for raising errors in the BMP2LED Class."""
 18      pass
 19  
 20  
 21  # pylint: disable=too-few-public-methods
 22  class BMPSpecs:
 23      """
 24      Contains vitals of a BMP2LED's active BMP file.
 25      Returned by the read_header() function.
 26      """
 27      def __init__(self, width, height, image_offset, flip):
 28          """
 29          BMPSpecs constructor.
 30          Arguments:
 31              width (int)        : BMP image width in pixels.
 32              height (int)       : BMP image height in pixels.
 33              image_offset (int) : Offset from start of file to first byte of
 34                                   pixel data.
 35              flip (boolean)     : True if image is stored bottom-to-top,
 36                                   vs top-to-bottom.
 37          """
 38          self.width = width
 39          self.height = height
 40          self.image_offset = image_offset
 41          self.flip = flip
 42          self.row_size = (width * 3 + 3) & ~3 # 32-bit line boundary
 43  
 44  
 45  class BMP2LED:
 46      """
 47      Handles conversion of BMP images to a binary file of DotStar-ready
 48      rows that can be read and passed directly to the SPI write() function.
 49      Intended for light painting projects.
 50      """
 51  
 52      def __init__(self, num_pixels, order='brg', gamma=2.4):
 53          """
 54          Constructor for BMP2LED Class. Arguments are values that are not
 55          expected to change over the life of the object.
 56          Arguments:
 57              num_pixels (int) : Number of pixels in DotStar strip.
 58              order (string)   : DotStar data color order. Optional, default
 59                                 is 'brg', used on most strips.
 60              gamma (float)    : Optional gamma-correction constant, for
 61                                 more perceptually-linear output.
 62                                 Optional; 2.4 if unspecified.
 63          """
 64          order = order.lower()
 65          self.red_index = order.find('r')
 66          self.green_index = order.find('g')
 67          self.blue_index = order.find('b')
 68          self.num_pixels = num_pixels
 69          self.gamma = gamma
 70          self.bmp_file = None
 71          self.bmp_specs = None
 72  
 73  
 74      def read_le(self, num_bytes):
 75          """
 76          Little-endian read from active BMP file.
 77          Arguments:
 78              num_bytes (int) : Number of bytes to read from file and convert
 79                                to integer value, little-end (least
 80                                significant byte) first. Typically 2 or 4.
 81          Returns:
 82              Converted integer product.
 83          """
 84          result = 0
 85          for byte_index, byte in enumerate(self.bmp_file.read(num_bytes)):
 86              result += byte << (byte_index * 8)
 87          return result
 88  
 89  
 90      def read_header(self):
 91          """
 92          Read and validate BMP file heaader. Throws exception if file
 93          attributes are incorrect (e.g. unsupported BMP variant).
 94          Returns:
 95              BMPSpecs object containing size, offset, etc.
 96          """
 97          if self.bmp_file.read(2) != b'BM': # Check signature
 98              raise BMPError("Not BMP file")
 99  
100          self.bmp_file.read(8) # Read & ignore file size & creator bytes
101  
102          image_offset = self.read_le(4) # Start of image data
103          self.bmp_file.read(4) # Read & ignore header size
104          width = self.read_le(4)
105          height = self.read_le(4)
106          # BMPs are traditionally stored bottom-to-top.
107          # If bmp_height is negative, image is in top-down order.
108          # This is not BMP canon but has been observed in the wild!
109          flip = True
110          if height < 0:
111              height = -height
112              flip = False
113  
114          if self.read_le(2) != 1:
115              raise BMPError("Not single-plane")
116          if self.read_le(2) != 24: # bits per pixel
117              raise BMPError("Not 24-bit")
118          if self.read_le(2) != 0:
119              raise BMPError("Compressed file")
120  
121          return BMPSpecs(width, height, image_offset, flip)
122  
123  
124      def scandir(self, path):
125          """
126          Scan a given path, looking for compatible BMP image files.
127          Arguments:
128              path (string) : Directory to search. If '', root path is used.
129          Returns:
130              List of compatible BMP filenames within path. Path is NOT
131              included in names. Subdirectories, non-BMP files and unsupported
132              BMP formats (e.g. compressed or paletted) are skipped.
133              List will be alphabetically sorted.
134          """
135          full_list = os.listdir(path)
136          valid_list = []
137          for entry in full_list:
138              try:
139                  with open(path + '/' + entry, 'rb') as self.bmp_file:
140                      self.read_header()
141                      valid_list.append(entry)
142              except (OSError, BMPError):
143                  continue
144  
145          valid_list.sort() # Alphabetize
146          return valid_list
147  
148  
149      def read_row(self, row, dest):
150          """
151          Read one row of pixels from BMP file, clipped to minimum of BMP
152          image width or LED strip length.
153          Arguments:
154              row (int)            : Index of row to read (0 to (img height-1)).
155              dest (uint8 ndarray) : Destination buffer. After reading, buffer
156                                     contains pixel data in BMP-native order
157                                     (B,G,R per pixel), no need to reorder to
158                                     DotStar order until later.
159          """
160          # 'flip' logic is intentionally backwards from typical BMP loader,
161          # this makes BMP image prep an easy 90 degree CCW rotation.
162          if not self.bmp_specs.flip:
163              row = self.bmp_specs.height - 1 - row
164          self.bmp_file.seek(self.bmp_specs.image_offset +
165                             row * self.bmp_specs.row_size)
166          self.bmp_file.readinto(dest)
167  
168  
169      # pylint: disable=too-many-arguments, too-many-locals
170      # pylint: disable=too-many-branches, too-many-statements
171      def process(self, input_filename, output_filename, rows,
172                  brightness=1.0, loop=False, callback=None):
173          """
174          Process a 24-bit uncompressed BMP file into a series of
175          DotStar-ready rows of bytes (including header and footer) written
176          to a binary file. The input image is stretched to a specified
177          number of rows, applying linear interpolation and error diffusion
178          dithering along the way. If BMP rows are narrower than LED strip
179          length, image be displayed at start of strip. If BMP rows are
180          wider, image will be cropped. Strongly recommended to call
181          gc.collect() after this function for smoothest playback.
182          Arguments:
183              input_filename (string)  : Full path and filename of BMP image.
184              output_filename (string) : Full path and filename of binary
185                                         output file (DotStar-ready rows).
186                                         EXISTING FILE WILL BE RUDELY AND
187                                         IMMEDIATELY DELETED (and contents
188                                         likely replaced), even if function
189                                         fails to finish.
190              rows (int)               : Number of rows to write to output
191                                         file; image will be stretched.
192                                         Actual number of rows may be less
193                                         than this depending on storage space.
194              brightness (float)       : Overall brightness adjustment, from 0.0
195                                         (off) to 1.0 (maximum brightness),
196                                         or None to use default (1.0). Since
197                                         app is expected to call spi.write()
198                                         directly, the conventional DotStar
199                                         brightness setting is not observed,
200                                         only the value specified here.
201              loop (boolean)           : If True, image playback to DotStar
202                                         strip will be repeated (end of list
203                                         needs to be represented differently
204                                         for looped vs. non-looped playback).
205              callback (func)          : Callback function for displaying load
206                                         progress, will be passed a float
207                                         ranging from 0.0 (start) to 1.0 (end).
208          Returns: actual number of rows in output file (may be less than
209                   number of rows requested, depending on storage space.
210          """
211  
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 = ulab.numpy.array([0] * 4 +
217                                      [255, 0, 0, 0] * self.num_pixels +
218                                      [255] * ((self.num_pixels + 15) // 16),
219                                      dtype=ulab.numpy.uint8)
220          dotstar_row_size = len(dotstar_buffer)
221  
222          # Output rows are held in RAM and periodically written,
223          # marginally faster than writing each row separately.
224          output_buffer = bytearray(BUFFER_ROWS * dotstar_row_size)
225          output_position = 0
226  
227          # Delete old temporary file, if any
228          try:
229              os.remove(output_filename)
230          except OSError:
231              pass
232  
233          # Determine free space on drive
234          stats = os.statvfs('/')
235          bytes_free = stats[0] * stats[4]   # block size, free blocks
236          if not loop:                       # If not looping, leave space
237              bytes_free -= dotstar_row_size # for 'off' LED data at end.
238          # Clip the maximum number of output rows based on free space and
239          # the size (in bytes) of each DotStar row.
240          rows = min(rows, bytes_free // dotstar_row_size)
241  
242          try:
243              with open(input_filename, 'rb') as self.bmp_file:
244                  #print("File opened")
245  
246                  self.bmp_specs = self.read_header()
247  
248                  #print("WxH: (%d,%d)" % (self.bmp_specs.width,
249                  #                        self.bmp_specs.height))
250                  #print("Image format OK, reading data...")
251  
252                  # Constrain bytes-to-read to pixel strip length
253                  clipped_width = min(self.bmp_specs.width, self.num_pixels)
254                  row_bytes = 3 * clipped_width
255  
256                  # Each output row is interpolated from two BMP rows,
257                  # we'll call them 'a' and 'b' here.
258                  row_a_data = ulab.numpy.zeros(row_bytes, dtype=ulab.numpy.uint8)
259                  row_b_data = ulab.numpy.zeros(row_bytes, dtype=ulab.numpy.uint8)
260                  prev_row_a_index, prev_row_b_index = None, None
261  
262                  with open(output_filename, 'wb') as led_file:
263                      # To avoid continually appending to output file (a slow
264                      # operation), seek to where the end of the file would
265                      # be, write a nonsense byte there, then seek back to
266                      # the beginning. Significant improvement!
267                      led_file.seek((dotstar_row_size * rows) - 1)
268                      led_file.write(b'\0')
269                      led_file.seek(0)
270                      err = 0
271                      for row in range(rows): # For each output row...
272                          # Scale position into pixel space...
273                          if loop: # 0 to <image height
274                              position = self.bmp_specs.height * row / rows
275                          else:    # 0 to last row.0
276                              position = (row / (rows - 1) *
277                                          (self.bmp_specs.height - 1))
278  
279                          # Separate absolute position into several values:
280                          # integer 'a' and 'b' row indices, floating 'a' and
281                          # 'b' weights (0.0 to 1.0) for interpolation.
282                          row_b_weight, row_a_index = math.modf(position)
283                          row_a_index = min(int(row_a_index),
284                                            self.bmp_specs.height - 1)
285                          row_b_index = (row_a_index + 1) % self.bmp_specs.height
286                          row_a_weight = 1.0 - row_b_weight
287  
288                          # New data ONLY needs reading if row index changed
289                          # (else do another interp/dither with existing data)
290                          if row_a_index != prev_row_a_index:
291                              # If we've advanced exactly one row, reassign
292                              # old 'b' data to 'a' row (swap, so buffers
293                              # remain distinct), else read new 'a'.
294                              if row_a_index == prev_row_b_index:
295                                  row_a_data, row_b_data = row_b_data, row_a_data
296                              else:
297                                  self.read_row(row_a_index, row_a_data)
298                              # Read new 'b' data on any row change
299                              self.read_row(row_b_index, row_b_data)
300  
301                          prev_row_a_index = row_a_index
302                          prev_row_b_index = row_b_index
303  
304                          # Pixel values are stored as bytes from 0-255.
305                          # Gamma correction requires floats from 0.0 to 1.0.
306                          # So there's a scaling operation involved, BUT, as
307                          # configurable brightness is also a thing, we can
308                          # work that into the same operation. Rather than
309                          # dividing pixels by 255, multiply by
310                          # brightness / 255. This reduces the two row
311                          # interpolation weights from 0.0-1.0 to
312                          # 0.0-brightness/255.
313                          row_a_weight *= brightness / 255
314                          row_b_weight *= brightness / 255
315  
316                          # 'want' is an ndarray of the idealized (as in,
317                          # floating-point) pixel values resulting from the
318                          # interpolation, with gamma correction applied and
319                          # scaled back up to 8-bit range. Scaling to 254.999
320                          # (not 255) lets us avoid a subsequent clip check.
321                          want = ((((row_a_data * row_a_weight) +
322                                    (row_b_data * row_b_weight)) **
323                                   self.gamma) * 254.999)
324  
325                          # 'got' will be an ndarray of the values that get
326                          # issued to the LED strip, formed through several
327                          # operations. First, the 'want' values are quantized
328                          # to uint8's -- so these will always be slightly
329                          # dimmer (v. occasionally equal) to the 'want' vals.
330                          got = ulab.numpy.array(want, dtype=ulab.numpy.uint8)
331                          # Note: naive 'foo = foo + bar' syntax used in this
332                          # next section is intentional. ndarrays don't seem
333                          # to always play well with '+=' syntax.
334                          # The difference between what we want and what we
335                          # got will be an ndarray of values from 0.0 to <1.0.
336                          # This is accumulated into the error ndarray to be
337                          # applied to this and subsequent rows.
338                          err = err + want - got
339                          # Accumulated error vals will all now be 0.0 to <2.0.
340                          # Quantizing err into a new uint8 ndarray, all values
341                          # will be 0 or 1.
342                          err_bits = ulab.numpy.array(err, dtype=ulab.numpy.uint8)
343                          # Add the 1's back into 'got', increasing the
344                          # brightness of certain pixels by 1. Because the max
345                          # value in 'got' is 254 (not 255), no clipping need
346                          # be performed, everything still fits in uint8.
347                          got = got + err_bits
348                          # Subtract those applied 1's from the error array,
349                          # leaving residue in the range 0.0 to <1.0 which
350                          # will be used on subsequent rows.
351                          err = err - err_bits
352  
353                          # Reorder data from BGR to DotStar color order,
354                          # allowing for header and start-of-pixel markers
355                          # in the DotStar data.
356                          dotstar_buffer[5 + self.blue_index:
357                                         5 + 4 * clipped_width:4] = got[0::3]
358                          dotstar_buffer[5 + self.green_index:
359                                         5 + 4 * clipped_width:4] = got[1::3]
360                          dotstar_buffer[5 + self.red_index:
361                                         5 + 4 * clipped_width:4] = got[2::3]
362                          output_buffer[output_position:output_position +
363                                        dotstar_row_size] = memoryview(
364                                            dotstar_buffer)
365  
366                          # Add converted data to output buffer.
367                          # Periodically write when full.
368                          output_position += dotstar_row_size
369                          if output_position >= len(output_buffer):
370                              led_file.write(output_buffer)
371                              if callback:
372                                  callback(row / (rows - 1))
373                              output_position = 0
374  
375                      # Write any remaining buffered data
376                      if output_position:
377                          led_file.write(output_buffer[:output_position])
378                          if callback:
379                              callback(1.0)
380  
381                      # If not looping, add an 'all off' row of LED data
382                      # at end to ensure last row timing is consistent.
383                      if not loop:
384                          rows += 1
385                          led_file.write(bytearray([0] * 4 +
386                                                   [255, 0, 0, 0] *
387                                                   self.num_pixels +
388                                                   [255] *
389                                                   ((self.num_pixels + 15) //
390                                                    16)))
391  
392                  #print("Loaded OK!")
393                  return rows
394  
395          except OSError as err:
396              if err.args[0] == 28:
397                  raise OSError("OS Error 28 0.25")
398              else:
399                  raise OSError("OS Error 0.5")
400          except BMPError as err:
401              print("Failed to parse BMP: " + err.args[0])