/ 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])