/ Pi_Matrix_Cube / life.py
life.py
1 # SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries 2 # 3 # SPDX-License-Identifier: MIT 4 5 """ 6 Conway's Game of Life for 6X square RGB LED matrices. 7 Uses same physical matrix arrangement as "globe" program; see notes there. 8 9 usage: sudo python life.py [options] 10 11 (You may or may not need the 'sudo' depending how the rpi-rgb-matrix 12 library is configured) 13 14 Options include all of the rpi-rgb-matrix flags, such as --led-pwm-bits=N 15 or --led-gpio-slowdown=N, and then the following: 16 17 -k <int> : Index of color palette to use. 0 = default black & white 18 (sorry, -c and -p already taken by matrix configurables). 19 -t <float> : Run time in seconds. Program will exit after this. 20 Default is to run indefinitely, until crtl+C received. 21 -f <float> : Fade in/out time in seconds. Used in combination with the 22 -t option, this provides a nice fade-in, run for a 23 while, fade-out and exit. 24 25 rpi-rgb-matrix has the following single-character abbreviations for 26 some configurables: -b (--led-brightness), -c (--led-chain), 27 -m (--led-gpio-mapping), -p (--led-pwm-bits), -P (--led-parallel), 28 -r (--led-rows). AVOID THESE in any future configurables added to this 29 program, as some users may have "muscle memory" for those options. 30 31 This code depends on the rpi-rgb-matrix library. While this .py file has 32 a permissive MIT licence, libraries may (or not) have restrictions on 33 commercial use, distributing precompiled binaries, etc. Check their 34 license terms if this is relevant to your situation. 35 """ 36 37 import argparse 38 import os 39 import sys 40 import time 41 import random 42 from rgbmatrix import RGBMatrix, RGBMatrixOptions 43 from PIL import Image 44 45 # import cProfile # Used only when profiling 46 47 EDGE_TOP = 0 48 EDGE_LEFT = 1 49 EDGE_RIGHT = 2 50 EDGE_BOTTOM = 3 51 FACE = ( # Topology for 6 faces of cube; constant, not runtime-configurable. 52 # Sequence within each face is top, left, right, bottom. 53 # Top, left, etc. are with respect to exterior view of LED face sides. 54 ( # For face[0]... 55 (1, EDGE_LEFT), # Top edge connects to left of face[1] 56 (2, EDGE_TOP), # Left edge connects to top of face[2] 57 (4, EDGE_TOP), # Right edge connects to top of face[4] 58 (3, EDGE_RIGHT), # etc... 59 ), 60 ( # face[1]... 61 (2, EDGE_LEFT), # Top edge connects to left of face[2] 62 (0, EDGE_TOP), # etc... 63 (5, EDGE_TOP), 64 (4, EDGE_RIGHT), 65 ), 66 ( 67 (0, EDGE_LEFT), 68 (1, EDGE_TOP), 69 (3, EDGE_TOP), 70 (5, EDGE_RIGHT), 71 ), 72 ( 73 (2, EDGE_RIGHT), 74 (5, EDGE_BOTTOM), 75 (0, EDGE_BOTTOM), 76 (4, EDGE_LEFT), 77 ), 78 ( 79 (0, EDGE_RIGHT), 80 (3, EDGE_BOTTOM), 81 (1, EDGE_BOTTOM), 82 (5, EDGE_LEFT), 83 ), 84 ( 85 (1, EDGE_RIGHT), 86 (4, EDGE_BOTTOM), 87 (2, EDGE_BOTTOM), 88 (3, EDGE_LEFT), 89 ), 90 ) 91 92 # Colormaps appear reversed from what one might expect. The first element 93 # of each is the 'on' pixel color, and each subsequent element is the color 94 # as a pixel 'ages,' up to the final 'background' color. Hence simple B&W 95 # on/off palette is white in index 0, black in index 1. 96 COLORMAP = ( 97 ((255, 255, 255), (0, 0, 0)), # Simple B&W 98 ( # Log2 Grayscale 99 (255, 255, 255), 100 (127, 127, 127), 101 (63, 63, 63), 102 (31, 31, 31), 103 (15, 15, 15), 104 (7, 7, 7), 105 (3, 3, 3), 106 (1, 1, 1), 107 (0, 0, 0), 108 ), 109 ( # Heatmap (white-yellow-red-black) 110 (255, 255, 255), # White 111 (255, 255, 127), # Two steps to... 112 (255, 255, 0), # Yellow 113 (255, 170, 0), # Three steps... 114 (255, 85, 0), 115 (255, 0, 0), # Red 116 (204, 0, 0), # Four steps... 117 (153, 0, 0), 118 (102, 0, 0), 119 (51, 0, 0), 120 (0, 0, 0), # Black 121 ), 122 ( # Spectrum 123 (255, 255, 255), # White (100%) 124 (127, 0, 0), # Red (50%) 125 (127, 31, 0), 126 (127, 63, 0), # Orange (50%) 127 (127, 95, 0), 128 (127, 127, 0), # Yellow (etc) 129 (63, 127, 0), 130 (0, 127, 0), # Green 131 (0, 127, 127), # Cyan 132 (0, 0, 127), # Blue 133 (63, 0, 127), 134 (127, 0, 127), # Magenta 135 (82, 0, 82), 136 (41, 0, 41), 137 (0, 0, 0), # Black 138 ), 139 ) 140 141 # pylint: disable=too-many-instance-attributes 142 class Life: 143 """ 144 Conway's Game of Life, mapped on a cube. See, the trick is that you 145 can't just treat it as a big 2D rectangle...faces may be arranged in 146 different orientations, and the space is discontiguous...the edges 147 and corners create shenanigans. 148 """ 149 150 def __init__(self): 151 self.matrix = None # RGB matrix object (initialized after inputs) 152 self.canvas = None # Offscreen canvas (after inputs) 153 self.matrix_size = 0 # Matrix width/height in pixels (after inputs) 154 self.matrix_max = 0 # Maximum column/row (after inputs) 155 self.data = None # Pixel 'age' data (after inputs) 156 self.direct = None # Table of 'OK to read pixel data directly' flags 157 self.idx = 0 # Currently active data index (0/1, double-buffered) 158 self.run_time = -1.0 # If >0 (input can override), limit run time 159 self.fade_time = 0.0 # Fade in/out time (input can override) 160 self.max_brightness = 255 # Matrix brightness (input can override) 161 self.chain_length = 6 # Matrix chain length 162 self.colormap = COLORMAP[0] # Input can override 163 self.colormap_max = None # Initialized after inputs 164 self.imgbuf = None # PIL image buffer (initialized after inputs) 165 166 # pylint: disable=too-many-statements 167 def setup(self): 168 """ Returns False on success, True on error """ 169 parser = argparse.ArgumentParser() 170 171 # RGB matrix standards 172 parser.add_argument( 173 "-r", 174 "--led-rows", 175 action="store", 176 help="Display rows. 32 for 32x32, 64 for 64x64. Default: 64", 177 default=64, 178 type=int, 179 ) 180 parser.add_argument( 181 "--led-cols", 182 action="store", 183 help="Panel columns. Typically 32 or 64. (Default: 64)", 184 default=64, 185 type=int, 186 ) 187 parser.add_argument( 188 "-c", 189 "--led-chain", 190 action="store", 191 help="Daisy-chained boards. Default: 6.", 192 default=6, 193 type=int, 194 ) 195 parser.add_argument( 196 "-P", 197 "--led-parallel", 198 action="store", 199 help="For Plus-models or RPi2: parallel chains. 1..3. Default: 1", 200 default=1, 201 type=int, 202 ) 203 parser.add_argument( 204 "-p", 205 "--led-pwm-bits", 206 action="store", 207 help="Bits used for PWM. Something between 1..11. Default: 11", 208 default=11, 209 type=int, 210 ) 211 parser.add_argument( 212 "-b", 213 "--led-brightness", 214 action="store", 215 help="Sets brightness level. Default: 100. Range: 1..100", 216 default=100, 217 type=int, 218 ) 219 parser.add_argument( 220 "-m", 221 "--led-gpio-mapping", 222 help="Hardware Mapping: regular, adafruit-hat, adafruit-hat-pwm", 223 choices=["regular", "regular-pi1", "adafruit-hat", "adafruit-hat-pwm"], 224 type=str, 225 ) 226 parser.add_argument( 227 "--led-scan-mode", 228 action="store", 229 help="Progressive or interlaced scan. 0 Progressive, 1 Interlaced (default)", 230 default=1, 231 choices=range(2), 232 type=int, 233 ) 234 parser.add_argument( 235 "--led-pwm-lsb-nanoseconds", 236 action="store", 237 help="Base time-unit for the on-time in the lowest " 238 "significant bit in nanoseconds. Default: 130", 239 default=130, 240 type=int, 241 ) 242 parser.add_argument( 243 "--led-show-refresh", 244 action="store_true", 245 help="Shows the current refresh rate of the LED panel", 246 ) 247 parser.add_argument( 248 "--led-slowdown-gpio", 249 action="store", 250 help="Slow down writing to GPIO. Range: 0..4. Default: 3", 251 default=4, # For Pi 4 w/6 matrices 252 type=int, 253 ) 254 parser.add_argument( 255 "--led-no-hardware-pulse", 256 action="store", 257 help="Don't use hardware pin-pulse generation", 258 ) 259 parser.add_argument( 260 "--led-rgb-sequence", 261 action="store", 262 help="Switch if your matrix has led colors swapped. Default: RGB", 263 default="RGB", 264 type=str, 265 ) 266 parser.add_argument( 267 "--led-pixel-mapper", 268 action="store", 269 help='Apply pixel mappers. e.g "Rotate:90"', 270 default="", 271 type=str, 272 ) 273 parser.add_argument( 274 "--led-row-addr-type", 275 action="store", 276 help="0 = default; 1=AB-addressed panels; 2=row direct; " 277 "3=ABC-addressed panels; 4 = ABC Shift + DE direct", 278 default=0, 279 type=int, 280 choices=[0, 1, 2, 3, 4], 281 ) 282 parser.add_argument( 283 "--led-multiplexing", 284 action="store", 285 help="Multiplexing type: 0=direct; 1=strip; 2=checker; 3=spiral; " 286 "4=ZStripe; 5=ZnMirrorZStripe; 6=coreman; 7=Kaler2Scan; " 287 "8=ZStripeUneven... (Default: 0)", 288 default=0, 289 type=int, 290 ) 291 parser.add_argument( 292 "--led-panel-type", 293 action="store", 294 help="Needed to initialize special panels. Supported: 'FM6126A'", 295 default="", 296 type=str, 297 ) 298 parser.add_argument( 299 "--led-no-drop-privs", 300 dest="drop_privileges", 301 help="Don't drop privileges from 'root' after initializing the hardware.", 302 action="store_false", 303 ) 304 305 # Extra args unique to this program 306 parser.add_argument( 307 "-k", 308 action="store", 309 help="Index of color palette to use. Default: 0", 310 default=0, 311 type=int, 312 ) 313 parser.add_argument( 314 "-t", 315 action="store", 316 help="Run time in seconds. Default: run indefinitely", 317 default=-1.0, 318 type=float, 319 ) 320 parser.add_argument( 321 "-f", 322 action="store", 323 help="Fade in/out time in seconds. Default: 0.0", 324 default=0.0, 325 type=float, 326 ) 327 328 parser.set_defaults(drop_privileges=True) 329 330 args = parser.parse_args() 331 332 if args.led_rows != args.led_cols: 333 print( 334 os.path.basename(__file__) + ": error: led rows and columns must match" 335 ) 336 return True 337 338 if args.led_chain * args.led_parallel != 6: 339 print( 340 os.path.basename(__file__) 341 + ": error: total chained * parallel matrices must equal 6" 342 ) 343 return True 344 345 options = RGBMatrixOptions() 346 347 if args.led_gpio_mapping is not None: 348 options.hardware_mapping = args.led_gpio_mapping 349 options.rows = args.led_rows 350 options.cols = args.led_cols 351 options.chain_length = args.led_chain 352 options.parallel = args.led_parallel 353 options.row_address_type = args.led_row_addr_type 354 options.multiplexing = args.led_multiplexing 355 options.pwm_bits = args.led_pwm_bits 356 options.brightness = args.led_brightness 357 options.pwm_lsb_nanoseconds = args.led_pwm_lsb_nanoseconds 358 options.led_rgb_sequence = args.led_rgb_sequence 359 options.pixel_mapper_config = args.led_pixel_mapper 360 options.panel_type = args.led_panel_type 361 362 if args.led_show_refresh: 363 options.show_refresh_rate = 1 364 365 if args.led_slowdown_gpio is not None: 366 options.gpio_slowdown = args.led_slowdown_gpio 367 if args.led_no_hardware_pulse: 368 options.disable_hardware_pulsing = True 369 if not args.drop_privileges: 370 options.drop_privileges = False 371 372 self.matrix = RGBMatrix(options=options) 373 self.canvas = self.matrix.CreateFrameCanvas() 374 self.matrix_size = args.led_rows 375 self.matrix_max = self.matrix_size - 1 376 self.chain_length = args.led_chain 377 self.max_brightness = args.led_brightness * 2.55 # 0-100 -> 0-255 378 self.run_time = args.t 379 self.fade_time = args.f 380 381 self.colormap = COLORMAP[min(max(args.k, 0), len(COLORMAP) - 1)] 382 self.colormap_max = len(self.colormap) - 1 383 384 # Alloc & randomize initial state; 50% chance of any pixel being set 385 self.data = [ 386 [ 387 [ 388 [ 389 random.randrange(2) * self.colormap_max 390 for x in range(self.matrix_size) 391 ] 392 for y in range(self.matrix_size) 393 ] 394 for face in range(6) 395 ] 396 for i in range(2) 397 ] 398 399 # Rather than testing X & Y to see if we should use get_edge_pixel 400 # or access the data array directly, this table pre-stores which 401 # pixel-getting approach to use for each row/column of one face. 402 self.direct = ( 403 [[False] * self.matrix_size] 404 + [[False] + [True] * (self.matrix_size - 2) + [False]] 405 * (self.matrix_size - 2) 406 + [[False] * self.matrix_size] 407 ) 408 409 self.imgbuf = bytearray(self.matrix_size * self.matrix_size * 3) 410 411 return False 412 413 # NOTE: if the code starts looking super atrocious from here down, 414 # that's no coincidence. To keep the animation smooth and appealing, 415 # this was written to be fast, not Pythonic. Tons of A/B testing was 416 # performed against different approaches to each piece, using cProfile 417 # and/or the displayed FPS values. Some of this looks REALLY bad. If 418 # you're wondering "why didn't they just [X]?", that's why. 419 # NOT GOOD CODE TO LEARN FROM, except maybe for setting a bad example. 420 421 # pylint: disable=too-many-branches 422 def cross(self, face, col, row, edge): 423 """Given a face index and a column & row known to be ONE pixel 424 off ONE edge, return a new face index and a corresponding 425 column & row within that face's native coordinate system. 426 """ 427 to_edge = FACE[face][edge][1] 428 if edge == EDGE_TOP: 429 if to_edge == EDGE_TOP: 430 col, row = self.matrix_max - col, 0 431 elif to_edge == EDGE_LEFT: 432 col, row = 0, col 433 elif to_edge == EDGE_RIGHT: 434 col, row = self.matrix_max, self.matrix_max - col 435 else: 436 row = self.matrix_max 437 elif edge == EDGE_LEFT: 438 if to_edge == EDGE_TOP: 439 col, row = row, 0 440 elif to_edge == EDGE_LEFT: 441 col, row = 0, self.matrix_max - row 442 elif to_edge == EDGE_RIGHT: 443 col = self.matrix_max 444 else: 445 col, row = self.matrix_max - row, self.matrix_max 446 elif edge == EDGE_RIGHT: 447 if to_edge == EDGE_TOP: 448 col, row = self.matrix_max - row, 0 449 elif to_edge == EDGE_LEFT: 450 col = 0 451 elif to_edge == EDGE_RIGHT: 452 col, row = self.matrix_max, self.matrix_max - row 453 else: 454 col, row = row, self.matrix_max 455 else: 456 if to_edge == EDGE_TOP: 457 row = 0 458 elif to_edge == EDGE_LEFT: 459 col, row = 0, self.matrix_max - col 460 elif to_edge == EDGE_RIGHT: 461 col, row = self.matrix_max, col 462 else: 463 col, row = self.matrix_max - col, self.matrix_max 464 465 return FACE[face][edge][0], col, row 466 467 def get_edge_pixel(self, face, col, row): 468 """Given a face index and a column & row that might be in-bounds 469 OR one pixel off one or two edges, return 'age' of pixel, wrapping 470 around edges as appropriate. 471 """ 472 if 0 <= col <= self.matrix_max: # Pixel in X bounds 473 if 0 <= row <= self.matrix_max: # Pixel in Y bounds 474 return self.data[self.idx][face][row][col] 475 # Else pixel in X bounds, but out of Y bounds 476 edge = EDGE_TOP if row < 0 else EDGE_BOTTOM 477 elif 0 <= row <= self.matrix_max: # Pixel in Y bounds, off left/right 478 edge = EDGE_LEFT if col < 0 else EDGE_RIGHT 479 else: # Pixel off two edges; treat corners as "dead" 480 return 1 481 482 face, col, row = self.cross(face, col, row, edge) 483 return self.data[self.idx][face][row][col] 484 485 def run(self): 486 """Main loop of Life simulation.""" 487 start_time, frames = time.monotonic(), 0 488 489 while True: 490 if self.run_time > 0: # Handle fade in / fade out 491 elapsed = time.monotonic() - start_time 492 if elapsed >= self.run_time: 493 break 494 if elapsed < self.fade_time: 495 self.matrix.brightness = int( 496 self.max_brightness * elapsed / self.fade_time 497 ) 498 elif elapsed > (self.run_time - self.fade_time): 499 self.matrix.brightness = int( 500 self.max_brightness * (self.run_time - elapsed) / self.fade_time 501 ) 502 else: 503 self.matrix.brightness = self.max_brightness 504 505 self.iterate() # Process and render one frame 506 507 # Swap double-buffered canvas, show frames per second 508 self.canvas = self.matrix.SwapOnVSync(self.canvas) 509 frames += 1 510 print(frames / (time.monotonic() - start_time)) 511 512 # pylint: disable=too-many-locals 513 def iterate(self): 514 """Run one cycle of the Life simulation, drawing to offscreen canvas.""" 515 next_idx = 1 - self.idx # Destination 516 # Certain instance variables (ones referenced in inner loop) are 517 # copied to locals to speed up access. This is kind of a jerk thing 518 # to do and not "Pythonic," but anything for a boost in this code. 519 imgbuf = self.imgbuf 520 colormap = self.colormap 521 colormap_max = self.colormap_max 522 get_edge_pixel = self.get_edge_pixel 523 for face in range(6): 524 offset = 0 525 for row in range(0, self.matrix_size): 526 row_data = self.data[self.idx][face][row] 527 row_data_next = self.data[next_idx][face][row] 528 rm1 = row - 1 529 rp1 = row + 1 530 if row > 0: 531 above_data = self.data[self.idx][face][rm1] 532 if row < self.matrix_max: 533 below_data = self.data[self.idx][face][rp1] 534 cm1 = -1 535 col = 0 536 direct = self.direct[row] 537 for cp1 in range(1, self.matrix_size + 1): 538 neighbors = ( 539 ( 540 above_data[cm1], 541 above_data[col], 542 above_data[cp1], 543 row_data[cm1], 544 row_data[cp1], 545 below_data[cm1], 546 below_data[col], 547 below_data[cp1], 548 ) 549 if direct[col] 550 else ( 551 get_edge_pixel(face, cm1, rm1), 552 get_edge_pixel(face, col, rm1), 553 get_edge_pixel(face, cp1, rm1), 554 get_edge_pixel(face, cm1, row), 555 get_edge_pixel(face, cp1, row), 556 get_edge_pixel(face, cm1, rp1), 557 get_edge_pixel(face, col, rp1), 558 get_edge_pixel(face, cp1, rp1), 559 ) 560 ).count(0) 561 # Live cell w/2 or 3 neighbors continues, else dies. 562 # Empty cell w/3 neighbors goes live. 563 age = row_data[col] 564 if age == 0: # Pixel (col,row) is active 565 if not neighbors in (2, 3): 566 age = 1 # Pixel aging starts 567 else: # Pixel (col,row) is aged 568 if neighbors == 3: 569 age = 0 # Arise! 570 elif age < colormap_max: 571 age += 1 # Decay 572 row_data_next[col] = age 573 rgb = colormap[age] 574 imgbuf[offset] = rgb[0] 575 imgbuf[offset + 1] = rgb[1] 576 imgbuf[offset + 2] = rgb[2] 577 offset += 3 578 cm1 = col 579 col = cp1 580 image = Image.frombuffer( 581 "RGB", 582 (self.matrix_size, self.matrix_size), 583 bytes(imgbuf), 584 "raw", 585 "RGB", 586 0, 587 1, 588 ) 589 # Upper-left corner of face in canvas space: 590 xoffset = (face % self.chain_length) * self.matrix_size 591 yoffset = (face // self.chain_length) * self.matrix_size 592 self.canvas.SetImage(image, offset_x=xoffset, offset_y=yoffset) 593 self.idx = next_idx 594 595 596 # pylint: disable=superfluous-parens 597 if __name__ == "__main__": 598 life = Life() 599 if not (status := life.setup()): 600 try: 601 print("Press CTRL-C to stop") 602 life.run() 603 # cProfile.run('life.run()') # Used only when profiling 604 except KeyboardInterrupt: 605 print("Exiting\n") 606 sys.exit(status)