Adafruit_Learning_System_Gu.../Pi_Matrix_Cube/globe.py
2022-05-02 12:54:02 -07:00

555 lines
21 KiB
Python

# 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 <filename> : 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 <float> : 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 <int> : Antialiasing samples, per-axis. Range 1-8. Default is 1,
no supersampling. Fast hardware can sometimes go higher,
most should stick with 1.
-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, 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 <float> : 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 <float> : 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)