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