# SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries # # SPDX-License-Identifier: MIT """ IF GLOBES WERE SQUARE: a revolving "globe" for 6X square RGB LED matrices on Raspberry Pi w/Adafruit Matrix Bonnet or HAT. usage: sudo ./globe [options] usage: sudo python globe.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: -i : Image filename for texture map. MUST be JPEG image format. Default is maps/earth.jpg -v : Orient cube with vertices at top & bottom, rather than flat faces on top & bottom. No accompanying value. -s : Spin time in seconds (per revolution). Positive values will revolve in the correct direction for the Earth map. Negative values spin the opposite direction (magnitude specifies seconds), maybe useful for text, logos or Uranus. -a : Antialiasing samples, per-axis. Range 1-8. Default is 1, no supersampling. Fast hardware can sometimes go higher, most should stick with 1. -t : Run time in seconds. Program will exit after this. Default is to run indefinitely, until crtl+C received. -f : Fade in/out time in seconds. Used in combination with the -t option, this provides a nice fade-in, revolve for a while, fade-out and exit. Combined with a simple shell script, it provides a classy way to cycle among different planetoids/scenes/etc. without having to explicitly implement such a feature here. -e : Edge-to-edge physical measure of LED matrix. Combined with -E below, provides spatial compensation for edge bezels when matrices are arranged in a cube (i.e. pixels don't "jump" across the seam -- has a bit of dead space). -E : Edge-to-edge measure of opposite faces of assembled cube, used in combination with -e above. This will be a little larger than the -e value (lower/upper case is to emphasize this relationship). Units for both are arbitrary; use millimeters, inches, whatever, it's the ratio that's important. -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 is not great learning-from code, being a fairly direct mash-up of code taken from life.py and adapted from globe.cc. It's mostly here to fulfill a need to offer these demos in both C++ and Python versions. The C++ code is a little better commented. 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 math import os import sys import time from rgbmatrix import RGBMatrix, RGBMatrixOptions from PIL import Image VERTS = ( (0, 1, 3), # Vertex indices for UL, UR, LL of top face matrix (0, 4, 1), # " left (0, 3, 4), # " front face (7, 3, 6), # " right (2, 1, 6), # " back (5, 4, 6), # " bottom matrix ) SQUARE_COORDS = ( (-1, 1, 1), (-1, 1, -1), (1, 1, -1), (1, 1, 1), (-1, -1, 1), (-1, -1, -1), (1, -1, -1), (1, -1, 1), ) # Alternate coordinates for a rotated cube with points at poles. # Vertex indices are the same (does not need a new VERTS array), # relationships are the same, the whole thing is just pivoted to # "hang" from vertex 3 at top. I will NOT attempt ASCII art of this. XX = (26.0 / 9.0) ** 0.5 YY = (3.0 ** 0.5) / 3.0 CC = -0.5 # cos(120.0 * M_PI / 180.0); SS = 0.75 ** 0.5 # sin(120.0 * M_PI / 180.0); POINTY_COORDS = ( (-XX, YY, 0.0), # Vertex 0 = leftmost point (XX * CC, -YY, -XX * SS), # 1 (-XX * CC, YY, -XX * SS), # 2 (0.0, 3.0 ** 0.5, 0.0), # 3 = top (XX * CC, -YY, XX * SS), # 4 (0.0, -(3.0 ** 0.5), 0.0), # 5 = bottom (XX, -YY, 0.0), # 6 = rightmost point (-XX * CC, YY, XX * SS), # 7 ) class Globe: """ Revolving globe on a cube. """ # pylint: disable=too-many-instance-attributes, too-many-locals 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.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.samples_per_pixel = 1 # Total antialiasing samples per pixel self.map_width = 0 # Map image width in pixels self.map_data = None # Map image pixel data in RAM self.longitude = None # Table of longitude values self.latitude = None # Table of latitude values self.imgbuf = None # Image is rendered to this RGB buffer self.spin_time = 10.0 self.chain_length = 6 # pylint: disable=too-many-branches, 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( "-i", action="store", help="Image filename for texture map. Default: maps/earth.jpg", default="maps/earth.jpg", type=str, ) parser.add_argument( "-v", dest="pointy", help="Orient cube with vertices at top & bottom.", action="store_true", ) parser.add_argument( "-s", action="store", help="Spin time in seconds/revolution. Default: 10.0", default=10.0, type=float, ) parser.add_argument( "-a", action="store", help="Antialiasing samples/axis. Default: 1", default=1, 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.add_argument( "-e", action="store", help="Edge-to-edge measure of matrix.", default=1.0, type=float, ) parser.add_argument( "-E", action="store", help="Edge-to-edge measure of opposite cube faces.", default=1.0, type=float, ) parser.set_defaults(drop_privileges=True) parser.set_defaults(pointy=False) 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.chain_length = args.led_chain self.max_brightness = args.led_brightness self.run_time = args.t self.fade_time = args.f self.samples_per_pixel = args.a * args.a matrix_measure = args.e cube_measure = args.E self.spin_time = args.s try: image = Image.open(args.i) except FileNotFoundError: print( os.path.basename(__file__) + ": error: image file " + args.i + " not found" ) return True self.map_width = image.size[0] map_height = image.size[1] self.map_data = image.tobytes() # Longitude and latitude tables are 1-dimensional, # can do that because we iterate every pixel every frame. pixels = self.matrix.width * self.matrix.height subpixels = pixels * self.samples_per_pixel self.longitude = [0.0 for _ in range(subpixels)] self.latitude = [0 for _ in range(subpixels)] # imgbuf holds result for one face of cube self.imgbuf = bytearray(self.matrix_size * self.matrix_size * 3) coords = POINTY_COORDS if args.pointy else SQUARE_COORDS # Fill the longitude & latitude tables, one per subpixel. ll_index = 0 # Index into longitude[] and latitude[] arrays ratio = matrix_measure / cube_measure # Scale ratio offset = ((1.0 - ratio) + ratio / (self.matrix_size * args.a)) * 0.5 # Axis offset for face in range(6): upper_left = coords[VERTS[face][0]] upper_right = coords[VERTS[face][1]] lower_left = coords[VERTS[face][2]] for ypix in range(self.matrix_size): # For each pixel Y... for xpix in range(self.matrix_size): # For each pixel X... for yaa in range(args.a): # " antialiased sample Y... yfactor = offset + ratio * (ypix * args.a + yaa) / ( self.matrix_size * args.a ) for xaa in range(args.a): # " antialiased sample X... xfactor = offset + ratio * (xpix * args.a + xaa) / ( self.matrix_size * args.a ) # Figure out the pixel's 3D position in space... x3d = ( upper_left[0] + (lower_left[0] - upper_left[0]) * yfactor + (upper_right[0] - upper_left[0]) * xfactor ) y3d = ( upper_left[1] + (lower_left[1] - upper_left[1]) * yfactor + (upper_right[1] - upper_left[1]) * xfactor ) z3d = ( upper_left[2] + (lower_left[2] - upper_left[2]) * yfactor + (upper_right[2] - upper_left[2]) * xfactor ) # Then convert to polar coords on a sphere... self.longitude[ll_index] = ( (math.pi + math.atan2(-z3d, x3d)) / (math.pi * 2.0) * self.map_width ) % self.map_width self.latitude[ll_index] = int( ( math.pi * 0.5 - math.atan2(y3d, math.sqrt(x3d * x3d + z3d * z3d)) ) / math.pi * map_height ) ll_index += 1 return False def run(self): """Main loop.""" start_time, frames = time.monotonic(), 0 while True: elapsed = time.monotonic() - start_time if self.run_time > 0: # Handle fade in / fade out 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 loffset = ( (elapsed % abs(self.spin_time)) / abs(self.spin_time) * self.map_width ) if self.spin_time > 0: loffset = self.map_width - loffset self.render(loffset) # 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 render(self, loffset): """Render one frame of the globe animation, taking latitude offset as input.""" # 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 map_data = self.map_data lon = self.longitude lat = self.latitude samples = self.samples_per_pixel map_width = self.map_width ll_index = 0 # Index into longitude/latitude tables for face in range(6): img_index = 0 # Index into imgbuf[] for _ in range(self.matrix_size * self.matrix_size): red = green = blue = 0 for _ in range(samples): map_index = ( lat[ll_index] * map_width + (int(lon[ll_index] + loffset) % map_width) ) * 3 red += map_data[map_index] green += map_data[map_index + 1] blue += map_data[map_index + 2] ll_index += 1 imgbuf[img_index] = red // samples imgbuf[img_index + 1] = green // samples imgbuf[img_index + 2] = blue // samples img_index += 3 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) # pylint: disable=superfluous-parens if __name__ == "__main__": globe = Globe() if not (status := globe.setup()): try: print("Press CTRL-C to stop") globe.run() except KeyboardInterrupt: print("Exiting\n") sys.exit(status)