606 lines
22 KiB
Python
606 lines
22 KiB
Python
# SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries
|
|
#
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""
|
|
Conway's Game of Life for 6X square RGB LED matrices.
|
|
Uses same physical matrix arrangement as "globe" program; see notes there.
|
|
|
|
usage: sudo python life.py [options]
|
|
|
|
(You may or may not need the 'sudo' depending how the rpi-rgb-matrix
|
|
library is configured)
|
|
|
|
Options include all of the rpi-rgb-matrix flags, such as --led-pwm-bits=N
|
|
or --led-gpio-slowdown=N, and then the following:
|
|
|
|
-k <int> : Index of color palette to use. 0 = default black & white
|
|
(sorry, -c and -p already taken by matrix configurables).
|
|
-t <float> : Run time in seconds. Program will exit after this.
|
|
Default is to run indefinitely, until crtl+C received.
|
|
-f <float> : Fade in/out time in seconds. Used in combination with the
|
|
-t option, this provides a nice fade-in, run for a
|
|
while, fade-out and exit.
|
|
|
|
rpi-rgb-matrix has the following single-character abbreviations for
|
|
some configurables: -b (--led-brightness), -c (--led-chain),
|
|
-m (--led-gpio-mapping), -p (--led-pwm-bits), -P (--led-parallel),
|
|
-r (--led-rows). AVOID THESE in any future configurables added to this
|
|
program, as some users may have "muscle memory" for those options.
|
|
|
|
This code depends on the rpi-rgb-matrix library. While this .py file has
|
|
a permissive MIT licence, libraries may (or not) have restrictions on
|
|
commercial use, distributing precompiled binaries, etc. Check their
|
|
license terms if this is relevant to your situation.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
import time
|
|
import random
|
|
from rgbmatrix import RGBMatrix, RGBMatrixOptions
|
|
from PIL import Image
|
|
|
|
# import cProfile # Used only when profiling
|
|
|
|
EDGE_TOP = 0
|
|
EDGE_LEFT = 1
|
|
EDGE_RIGHT = 2
|
|
EDGE_BOTTOM = 3
|
|
FACE = ( # Topology for 6 faces of cube; constant, not runtime-configurable.
|
|
# Sequence within each face is top, left, right, bottom.
|
|
# Top, left, etc. are with respect to exterior view of LED face sides.
|
|
( # For face[0]...
|
|
(1, EDGE_LEFT), # Top edge connects to left of face[1]
|
|
(2, EDGE_TOP), # Left edge connects to top of face[2]
|
|
(4, EDGE_TOP), # Right edge connects to top of face[4]
|
|
(3, EDGE_RIGHT), # etc...
|
|
),
|
|
( # face[1]...
|
|
(2, EDGE_LEFT), # Top edge connects to left of face[2]
|
|
(0, EDGE_TOP), # etc...
|
|
(5, EDGE_TOP),
|
|
(4, EDGE_RIGHT),
|
|
),
|
|
(
|
|
(0, EDGE_LEFT),
|
|
(1, EDGE_TOP),
|
|
(3, EDGE_TOP),
|
|
(5, EDGE_RIGHT),
|
|
),
|
|
(
|
|
(2, EDGE_RIGHT),
|
|
(5, EDGE_BOTTOM),
|
|
(0, EDGE_BOTTOM),
|
|
(4, EDGE_LEFT),
|
|
),
|
|
(
|
|
(0, EDGE_RIGHT),
|
|
(3, EDGE_BOTTOM),
|
|
(1, EDGE_BOTTOM),
|
|
(5, EDGE_LEFT),
|
|
),
|
|
(
|
|
(1, EDGE_RIGHT),
|
|
(4, EDGE_BOTTOM),
|
|
(2, EDGE_BOTTOM),
|
|
(3, EDGE_LEFT),
|
|
),
|
|
)
|
|
|
|
# Colormaps appear reversed from what one might expect. The first element
|
|
# of each is the 'on' pixel color, and each subsequent element is the color
|
|
# as a pixel 'ages,' up to the final 'background' color. Hence simple B&W
|
|
# on/off palette is white in index 0, black in index 1.
|
|
COLORMAP = (
|
|
((255, 255, 255), (0, 0, 0)), # Simple B&W
|
|
( # Log2 Grayscale
|
|
(255, 255, 255),
|
|
(127, 127, 127),
|
|
(63, 63, 63),
|
|
(31, 31, 31),
|
|
(15, 15, 15),
|
|
(7, 7, 7),
|
|
(3, 3, 3),
|
|
(1, 1, 1),
|
|
(0, 0, 0),
|
|
),
|
|
( # Heatmap (white-yellow-red-black)
|
|
(255, 255, 255), # White
|
|
(255, 255, 127), # Two steps to...
|
|
(255, 255, 0), # Yellow
|
|
(255, 170, 0), # Three steps...
|
|
(255, 85, 0),
|
|
(255, 0, 0), # Red
|
|
(204, 0, 0), # Four steps...
|
|
(153, 0, 0),
|
|
(102, 0, 0),
|
|
(51, 0, 0),
|
|
(0, 0, 0), # Black
|
|
),
|
|
( # Spectrum
|
|
(255, 255, 255), # White (100%)
|
|
(127, 0, 0), # Red (50%)
|
|
(127, 31, 0),
|
|
(127, 63, 0), # Orange (50%)
|
|
(127, 95, 0),
|
|
(127, 127, 0), # Yellow (etc)
|
|
(63, 127, 0),
|
|
(0, 127, 0), # Green
|
|
(0, 127, 127), # Cyan
|
|
(0, 0, 127), # Blue
|
|
(63, 0, 127),
|
|
(127, 0, 127), # Magenta
|
|
(82, 0, 82),
|
|
(41, 0, 41),
|
|
(0, 0, 0), # Black
|
|
),
|
|
)
|
|
|
|
# pylint: disable=too-many-instance-attributes
|
|
class Life:
|
|
"""
|
|
Conway's Game of Life, mapped on a cube. See, the trick is that you
|
|
can't just treat it as a big 2D rectangle...faces may be arranged in
|
|
different orientations, and the space is discontiguous...the edges
|
|
and corners create shenanigans.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.matrix = None # RGB matrix object (initialized after inputs)
|
|
self.canvas = None # Offscreen canvas (after inputs)
|
|
self.matrix_size = 0 # Matrix width/height in pixels (after inputs)
|
|
self.matrix_max = 0 # Maximum column/row (after inputs)
|
|
self.data = None # Pixel 'age' data (after inputs)
|
|
self.direct = None # Table of 'OK to read pixel data directly' flags
|
|
self.idx = 0 # Currently active data index (0/1, double-buffered)
|
|
self.run_time = -1.0 # If >0 (input can override), limit run time
|
|
self.fade_time = 0.0 # Fade in/out time (input can override)
|
|
self.max_brightness = 255 # Matrix brightness (input can override)
|
|
self.chain_length = 6 # Matrix chain length
|
|
self.colormap = COLORMAP[0] # Input can override
|
|
self.colormap_max = None # Initialized after inputs
|
|
self.imgbuf = None # PIL image buffer (initialized after inputs)
|
|
|
|
# pylint: disable=too-many-statements
|
|
def setup(self):
|
|
""" Returns False on success, True on error """
|
|
parser = argparse.ArgumentParser()
|
|
|
|
# RGB matrix standards
|
|
parser.add_argument(
|
|
"-r",
|
|
"--led-rows",
|
|
action="store",
|
|
help="Display rows. 32 for 32x32, 64 for 64x64. Default: 64",
|
|
default=64,
|
|
type=int,
|
|
)
|
|
parser.add_argument(
|
|
"--led-cols",
|
|
action="store",
|
|
help="Panel columns. Typically 32 or 64. (Default: 64)",
|
|
default=64,
|
|
type=int,
|
|
)
|
|
parser.add_argument(
|
|
"-c",
|
|
"--led-chain",
|
|
action="store",
|
|
help="Daisy-chained boards. Default: 6.",
|
|
default=6,
|
|
type=int,
|
|
)
|
|
parser.add_argument(
|
|
"-P",
|
|
"--led-parallel",
|
|
action="store",
|
|
help="For Plus-models or RPi2: parallel chains. 1..3. Default: 1",
|
|
default=1,
|
|
type=int,
|
|
)
|
|
parser.add_argument(
|
|
"-p",
|
|
"--led-pwm-bits",
|
|
action="store",
|
|
help="Bits used for PWM. Something between 1..11. Default: 11",
|
|
default=11,
|
|
type=int,
|
|
)
|
|
parser.add_argument(
|
|
"-b",
|
|
"--led-brightness",
|
|
action="store",
|
|
help="Sets brightness level. Default: 100. Range: 1..100",
|
|
default=100,
|
|
type=int,
|
|
)
|
|
parser.add_argument(
|
|
"-m",
|
|
"--led-gpio-mapping",
|
|
help="Hardware Mapping: regular, adafruit-hat, adafruit-hat-pwm",
|
|
choices=["regular", "regular-pi1", "adafruit-hat", "adafruit-hat-pwm"],
|
|
type=str,
|
|
)
|
|
parser.add_argument(
|
|
"--led-scan-mode",
|
|
action="store",
|
|
help="Progressive or interlaced scan. 0 Progressive, 1 Interlaced (default)",
|
|
default=1,
|
|
choices=range(2),
|
|
type=int,
|
|
)
|
|
parser.add_argument(
|
|
"--led-pwm-lsb-nanoseconds",
|
|
action="store",
|
|
help="Base time-unit for the on-time in the lowest "
|
|
"significant bit in nanoseconds. Default: 130",
|
|
default=130,
|
|
type=int,
|
|
)
|
|
parser.add_argument(
|
|
"--led-show-refresh",
|
|
action="store_true",
|
|
help="Shows the current refresh rate of the LED panel",
|
|
)
|
|
parser.add_argument(
|
|
"--led-slowdown-gpio",
|
|
action="store",
|
|
help="Slow down writing to GPIO. Range: 0..4. Default: 3",
|
|
default=4, # For Pi 4 w/6 matrices
|
|
type=int,
|
|
)
|
|
parser.add_argument(
|
|
"--led-no-hardware-pulse",
|
|
action="store",
|
|
help="Don't use hardware pin-pulse generation",
|
|
)
|
|
parser.add_argument(
|
|
"--led-rgb-sequence",
|
|
action="store",
|
|
help="Switch if your matrix has led colors swapped. Default: RGB",
|
|
default="RGB",
|
|
type=str,
|
|
)
|
|
parser.add_argument(
|
|
"--led-pixel-mapper",
|
|
action="store",
|
|
help='Apply pixel mappers. e.g "Rotate:90"',
|
|
default="",
|
|
type=str,
|
|
)
|
|
parser.add_argument(
|
|
"--led-row-addr-type",
|
|
action="store",
|
|
help="0 = default; 1=AB-addressed panels; 2=row direct; "
|
|
"3=ABC-addressed panels; 4 = ABC Shift + DE direct",
|
|
default=0,
|
|
type=int,
|
|
choices=[0, 1, 2, 3, 4],
|
|
)
|
|
parser.add_argument(
|
|
"--led-multiplexing",
|
|
action="store",
|
|
help="Multiplexing type: 0=direct; 1=strip; 2=checker; 3=spiral; "
|
|
"4=ZStripe; 5=ZnMirrorZStripe; 6=coreman; 7=Kaler2Scan; "
|
|
"8=ZStripeUneven... (Default: 0)",
|
|
default=0,
|
|
type=int,
|
|
)
|
|
parser.add_argument(
|
|
"--led-panel-type",
|
|
action="store",
|
|
help="Needed to initialize special panels. Supported: 'FM6126A'",
|
|
default="",
|
|
type=str,
|
|
)
|
|
parser.add_argument(
|
|
"--led-no-drop-privs",
|
|
dest="drop_privileges",
|
|
help="Don't drop privileges from 'root' after initializing the hardware.",
|
|
action="store_false",
|
|
)
|
|
|
|
# Extra args unique to this program
|
|
parser.add_argument(
|
|
"-k",
|
|
action="store",
|
|
help="Index of color palette to use. Default: 0",
|
|
default=0,
|
|
type=int,
|
|
)
|
|
parser.add_argument(
|
|
"-t",
|
|
action="store",
|
|
help="Run time in seconds. Default: run indefinitely",
|
|
default=-1.0,
|
|
type=float,
|
|
)
|
|
parser.add_argument(
|
|
"-f",
|
|
action="store",
|
|
help="Fade in/out time in seconds. Default: 0.0",
|
|
default=0.0,
|
|
type=float,
|
|
)
|
|
|
|
parser.set_defaults(drop_privileges=True)
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.led_rows != args.led_cols:
|
|
print(
|
|
os.path.basename(__file__) + ": error: led rows and columns must match"
|
|
)
|
|
return True
|
|
|
|
if args.led_chain * args.led_parallel != 6:
|
|
print(
|
|
os.path.basename(__file__)
|
|
+ ": error: total chained * parallel matrices must equal 6"
|
|
)
|
|
return True
|
|
|
|
options = RGBMatrixOptions()
|
|
|
|
if args.led_gpio_mapping is not None:
|
|
options.hardware_mapping = args.led_gpio_mapping
|
|
options.rows = args.led_rows
|
|
options.cols = args.led_cols
|
|
options.chain_length = args.led_chain
|
|
options.parallel = args.led_parallel
|
|
options.row_address_type = args.led_row_addr_type
|
|
options.multiplexing = args.led_multiplexing
|
|
options.pwm_bits = args.led_pwm_bits
|
|
options.brightness = args.led_brightness
|
|
options.pwm_lsb_nanoseconds = args.led_pwm_lsb_nanoseconds
|
|
options.led_rgb_sequence = args.led_rgb_sequence
|
|
options.pixel_mapper_config = args.led_pixel_mapper
|
|
options.panel_type = args.led_panel_type
|
|
|
|
if args.led_show_refresh:
|
|
options.show_refresh_rate = 1
|
|
|
|
if args.led_slowdown_gpio is not None:
|
|
options.gpio_slowdown = args.led_slowdown_gpio
|
|
if args.led_no_hardware_pulse:
|
|
options.disable_hardware_pulsing = True
|
|
if not args.drop_privileges:
|
|
options.drop_privileges = False
|
|
|
|
self.matrix = RGBMatrix(options=options)
|
|
self.canvas = self.matrix.CreateFrameCanvas()
|
|
self.matrix_size = args.led_rows
|
|
self.matrix_max = self.matrix_size - 1
|
|
self.chain_length = args.led_chain
|
|
self.max_brightness = args.led_brightness * 2.55 # 0-100 -> 0-255
|
|
self.run_time = args.t
|
|
self.fade_time = args.f
|
|
|
|
self.colormap = COLORMAP[min(max(args.k, 0), len(COLORMAP) - 1)]
|
|
self.colormap_max = len(self.colormap) - 1
|
|
|
|
# Alloc & randomize initial state; 50% chance of any pixel being set
|
|
self.data = [
|
|
[
|
|
[
|
|
[
|
|
random.randrange(2) * self.colormap_max
|
|
for x in range(self.matrix_size)
|
|
]
|
|
for y in range(self.matrix_size)
|
|
]
|
|
for face in range(6)
|
|
]
|
|
for i in range(2)
|
|
]
|
|
|
|
# Rather than testing X & Y to see if we should use get_edge_pixel
|
|
# or access the data array directly, this table pre-stores which
|
|
# pixel-getting approach to use for each row/column of one face.
|
|
self.direct = (
|
|
[[False] * self.matrix_size]
|
|
+ [[False] + [True] * (self.matrix_size - 2) + [False]]
|
|
* (self.matrix_size - 2)
|
|
+ [[False] * self.matrix_size]
|
|
)
|
|
|
|
self.imgbuf = bytearray(self.matrix_size * self.matrix_size * 3)
|
|
|
|
return False
|
|
|
|
# NOTE: if the code starts looking super atrocious from here down,
|
|
# that's no coincidence. To keep the animation smooth and appealing,
|
|
# this was written to be fast, not Pythonic. Tons of A/B testing was
|
|
# performed against different approaches to each piece, using cProfile
|
|
# and/or the displayed FPS values. Some of this looks REALLY bad. If
|
|
# you're wondering "why didn't they just [X]?", that's why.
|
|
# NOT GOOD CODE TO LEARN FROM, except maybe for setting a bad example.
|
|
|
|
# pylint: disable=too-many-branches
|
|
def cross(self, face, col, row, edge):
|
|
"""Given a face index and a column & row known to be ONE pixel
|
|
off ONE edge, return a new face index and a corresponding
|
|
column & row within that face's native coordinate system.
|
|
"""
|
|
to_edge = FACE[face][edge][1]
|
|
if edge == EDGE_TOP:
|
|
if to_edge == EDGE_TOP:
|
|
col, row = self.matrix_max - col, 0
|
|
elif to_edge == EDGE_LEFT:
|
|
col, row = 0, col
|
|
elif to_edge == EDGE_RIGHT:
|
|
col, row = self.matrix_max, self.matrix_max - col
|
|
else:
|
|
row = self.matrix_max
|
|
elif edge == EDGE_LEFT:
|
|
if to_edge == EDGE_TOP:
|
|
col, row = row, 0
|
|
elif to_edge == EDGE_LEFT:
|
|
col, row = 0, self.matrix_max - row
|
|
elif to_edge == EDGE_RIGHT:
|
|
col = self.matrix_max
|
|
else:
|
|
col, row = self.matrix_max - row, self.matrix_max
|
|
elif edge == EDGE_RIGHT:
|
|
if to_edge == EDGE_TOP:
|
|
col, row = self.matrix_max - row, 0
|
|
elif to_edge == EDGE_LEFT:
|
|
col = 0
|
|
elif to_edge == EDGE_RIGHT:
|
|
col, row = self.matrix_max, self.matrix_max - row
|
|
else:
|
|
col, row = row, self.matrix_max
|
|
else:
|
|
if to_edge == EDGE_TOP:
|
|
row = 0
|
|
elif to_edge == EDGE_LEFT:
|
|
col, row = 0, self.matrix_max - col
|
|
elif to_edge == EDGE_RIGHT:
|
|
col, row = self.matrix_max, col
|
|
else:
|
|
col, row = self.matrix_max - col, self.matrix_max
|
|
|
|
return FACE[face][edge][0], col, row
|
|
|
|
def get_edge_pixel(self, face, col, row):
|
|
"""Given a face index and a column & row that might be in-bounds
|
|
OR one pixel off one or two edges, return 'age' of pixel, wrapping
|
|
around edges as appropriate.
|
|
"""
|
|
if 0 <= col <= self.matrix_max: # Pixel in X bounds
|
|
if 0 <= row <= self.matrix_max: # Pixel in Y bounds
|
|
return self.data[self.idx][face][row][col]
|
|
# Else pixel in X bounds, but out of Y bounds
|
|
edge = EDGE_TOP if row < 0 else EDGE_BOTTOM
|
|
elif 0 <= row <= self.matrix_max: # Pixel in Y bounds, off left/right
|
|
edge = EDGE_LEFT if col < 0 else EDGE_RIGHT
|
|
else: # Pixel off two edges; treat corners as "dead"
|
|
return 1
|
|
|
|
face, col, row = self.cross(face, col, row, edge)
|
|
return self.data[self.idx][face][row][col]
|
|
|
|
def run(self):
|
|
"""Main loop of Life simulation."""
|
|
start_time, frames = time.monotonic(), 0
|
|
|
|
while True:
|
|
if self.run_time > 0: # Handle fade in / fade out
|
|
elapsed = time.monotonic() - start_time
|
|
if elapsed >= self.run_time:
|
|
break
|
|
if elapsed < self.fade_time:
|
|
self.matrix.brightness = int(
|
|
self.max_brightness * elapsed / self.fade_time
|
|
)
|
|
elif elapsed > (self.run_time - self.fade_time):
|
|
self.matrix.brightness = int(
|
|
self.max_brightness * (self.run_time - elapsed) / self.fade_time
|
|
)
|
|
else:
|
|
self.matrix.brightness = self.max_brightness
|
|
|
|
self.iterate() # Process and render one frame
|
|
|
|
# Swap double-buffered canvas, show frames per second
|
|
self.canvas = self.matrix.SwapOnVSync(self.canvas)
|
|
frames += 1
|
|
print(frames / (time.monotonic() - start_time))
|
|
|
|
# pylint: disable=too-many-locals
|
|
def iterate(self):
|
|
"""Run one cycle of the Life simulation, drawing to offscreen canvas."""
|
|
next_idx = 1 - self.idx # Destination
|
|
# Certain instance variables (ones referenced in inner loop) are
|
|
# copied to locals to speed up access. This is kind of a jerk thing
|
|
# to do and not "Pythonic," but anything for a boost in this code.
|
|
imgbuf = self.imgbuf
|
|
colormap = self.colormap
|
|
colormap_max = self.colormap_max
|
|
get_edge_pixel = self.get_edge_pixel
|
|
for face in range(6):
|
|
offset = 0
|
|
for row in range(0, self.matrix_size):
|
|
row_data = self.data[self.idx][face][row]
|
|
row_data_next = self.data[next_idx][face][row]
|
|
rm1 = row - 1
|
|
rp1 = row + 1
|
|
if row > 0:
|
|
above_data = self.data[self.idx][face][rm1]
|
|
if row < self.matrix_max:
|
|
below_data = self.data[self.idx][face][rp1]
|
|
cm1 = -1
|
|
col = 0
|
|
direct = self.direct[row]
|
|
for cp1 in range(1, self.matrix_size + 1):
|
|
neighbors = (
|
|
(
|
|
above_data[cm1],
|
|
above_data[col],
|
|
above_data[cp1],
|
|
row_data[cm1],
|
|
row_data[cp1],
|
|
below_data[cm1],
|
|
below_data[col],
|
|
below_data[cp1],
|
|
)
|
|
if direct[col]
|
|
else (
|
|
get_edge_pixel(face, cm1, rm1),
|
|
get_edge_pixel(face, col, rm1),
|
|
get_edge_pixel(face, cp1, rm1),
|
|
get_edge_pixel(face, cm1, row),
|
|
get_edge_pixel(face, cp1, row),
|
|
get_edge_pixel(face, cm1, rp1),
|
|
get_edge_pixel(face, col, rp1),
|
|
get_edge_pixel(face, cp1, rp1),
|
|
)
|
|
).count(0)
|
|
# Live cell w/2 or 3 neighbors continues, else dies.
|
|
# Empty cell w/3 neighbors goes live.
|
|
age = row_data[col]
|
|
if age == 0: # Pixel (col,row) is active
|
|
if not neighbors in (2, 3):
|
|
age = 1 # Pixel aging starts
|
|
else: # Pixel (col,row) is aged
|
|
if neighbors == 3:
|
|
age = 0 # Arise!
|
|
elif age < colormap_max:
|
|
age += 1 # Decay
|
|
row_data_next[col] = age
|
|
rgb = colormap[age]
|
|
imgbuf[offset] = rgb[0]
|
|
imgbuf[offset + 1] = rgb[1]
|
|
imgbuf[offset + 2] = rgb[2]
|
|
offset += 3
|
|
cm1 = col
|
|
col = cp1
|
|
image = Image.frombuffer(
|
|
"RGB",
|
|
(self.matrix_size, self.matrix_size),
|
|
bytes(imgbuf),
|
|
"raw",
|
|
"RGB",
|
|
0,
|
|
1,
|
|
)
|
|
# Upper-left corner of face in canvas space:
|
|
xoffset = (face % self.chain_length) * self.matrix_size
|
|
yoffset = (face // self.chain_length) * self.matrix_size
|
|
self.canvas.SetImage(image, offset_x=xoffset, offset_y=yoffset)
|
|
self.idx = next_idx
|
|
|
|
|
|
# pylint: disable=superfluous-parens
|
|
if __name__ == "__main__":
|
|
life = Life()
|
|
if not (status := life.setup()):
|
|
try:
|
|
print("Press CTRL-C to stop")
|
|
life.run()
|
|
# cProfile.run('life.run()') # Used only when profiling
|
|
except KeyboardInterrupt:
|
|
print("Exiting\n")
|
|
sys.exit(status)
|