/ Pi_Matrix_Cube / globe.py
globe.py
1 # SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries 2 # 3 # SPDX-License-Identifier: MIT 4 5 """ 6 IF GLOBES WERE SQUARE: a revolving "globe" for 6X square RGB LED matrices on 7 Raspberry Pi w/Adafruit Matrix Bonnet or HAT. 8 9 usage: sudo ./globe [options] 10 11 usage: sudo python globe.py [options] 12 13 (You may or may not need the 'sudo' depending how the rpi-rgb-matrix 14 library is configured) 15 16 Options include all of the rpi-rgb-matrix flags, such as --led-pwm-bits=N 17 or --led-gpio-slowdown=N, and then the following: 18 19 -i <filename> : Image filename for texture map. MUST be JPEG image format. 20 Default is maps/earth.jpg 21 -v : Orient cube with vertices at top & bottom, rather than 22 flat faces on top & bottom. No accompanying value. 23 -s <float> : Spin time in seconds (per revolution). Positive values 24 will revolve in the correct direction for the Earth map. 25 Negative values spin the opposite direction (magnitude 26 specifies seconds), maybe useful for text, logos or Uranus. 27 -a <int> : Antialiasing samples, per-axis. Range 1-8. Default is 1, 28 no supersampling. Fast hardware can sometimes go higher, 29 most should stick with 1. 30 -t <float> : Run time in seconds. Program will exit after this. 31 Default is to run indefinitely, until crtl+C received. 32 -f <float> : Fade in/out time in seconds. Used in combination with the 33 -t option, this provides a nice fade-in, revolve for a 34 while, fade-out and exit. Combined with a simple shell 35 script, it provides a classy way to cycle among different 36 planetoids/scenes/etc. without having to explicitly 37 implement such a feature here. 38 -e <float> : Edge-to-edge physical measure of LED matrix. Combined 39 with -E below, provides spatial compensation for edge 40 bezels when matrices are arranged in a cube (i.e. pixels 41 don't "jump" across the seam -- has a bit of dead space). 42 -E <float> : Edge-to-edge measure of opposite faces of assembled cube, 43 used in combination with -e above. This will be a little 44 larger than the -e value (lower/upper case is to emphasize 45 this relationship). Units for both are arbitrary; use 46 millimeters, inches, whatever, it's the ratio that's 47 important. 48 49 -rgb-matrix has the following single-character abbreviations for 50 some configurables: -b (--led-brightness), -c (--led-chain), 51 -m (--led-gpio-mapping), -p (--led-pwm-bits), -P (--led-parallel), 52 -r (--led-rows). AVOID THESE in any future configurables added to this 53 program, as some users may have "muscle memory" for those options. 54 55 This is not great learning-from code, being a fairly direct mash-up of 56 code taken from life.py and adapted from globe.cc. It's mostly here to 57 fulfill a need to offer these demos in both C++ and Python versions. 58 The C++ code is a little better commented. 59 60 This code depends on the rpi-rgb-matrix library. While this .py file has 61 a permissive MIT licence, libraries may (or not) have restrictions on 62 commercial use, distributing precompiled binaries, etc. Check their 63 license terms if this is relevant to your situation. 64 """ 65 66 import argparse 67 import math 68 import os 69 import sys 70 import time 71 from rgbmatrix import RGBMatrix, RGBMatrixOptions 72 from PIL import Image 73 74 VERTS = ( 75 (0, 1, 3), # Vertex indices for UL, UR, LL of top face matrix 76 (0, 4, 1), # " left 77 (0, 3, 4), # " front face 78 (7, 3, 6), # " right 79 (2, 1, 6), # " back 80 (5, 4, 6), # " bottom matrix 81 ) 82 83 SQUARE_COORDS = ( 84 (-1, 1, 1), 85 (-1, 1, -1), 86 (1, 1, -1), 87 (1, 1, 1), 88 (-1, -1, 1), 89 (-1, -1, -1), 90 (1, -1, -1), 91 (1, -1, 1), 92 ) 93 94 # Alternate coordinates for a rotated cube with points at poles. 95 # Vertex indices are the same (does not need a new VERTS array), 96 # relationships are the same, the whole thing is just pivoted to 97 # "hang" from vertex 3 at top. I will NOT attempt ASCII art of this. 98 XX = (26.0 / 9.0) ** 0.5 99 YY = (3.0 ** 0.5) / 3.0 100 CC = -0.5 # cos(120.0 * M_PI / 180.0); 101 SS = 0.75 ** 0.5 # sin(120.0 * M_PI / 180.0); 102 POINTY_COORDS = ( 103 (-XX, YY, 0.0), # Vertex 0 = leftmost point 104 (XX * CC, -YY, -XX * SS), # 1 105 (-XX * CC, YY, -XX * SS), # 2 106 (0.0, 3.0 ** 0.5, 0.0), # 3 = top 107 (XX * CC, -YY, XX * SS), # 4 108 (0.0, -(3.0 ** 0.5), 0.0), # 5 = bottom 109 (XX, -YY, 0.0), # 6 = rightmost point 110 (-XX * CC, YY, XX * SS), # 7 111 ) 112 113 114 class Globe: 115 """ 116 Revolving globe on a cube. 117 """ 118 119 # pylint: disable=too-many-instance-attributes, too-many-locals 120 def __init__(self): 121 self.matrix = None # RGB matrix object (initialized after inputs) 122 self.canvas = None # Offscreen canvas (after inputs) 123 self.matrix_size = 0 # Matrix width/height in pixels (after inputs) 124 self.run_time = -1.0 # If >0 (input can override), limit run time 125 self.fade_time = 0.0 # Fade in/out time (input can override) 126 self.max_brightness = 255 # Matrix brightness (input can override) 127 self.samples_per_pixel = 1 # Total antialiasing samples per pixel 128 self.map_width = 0 # Map image width in pixels 129 self.map_data = None # Map image pixel data in RAM 130 self.longitude = None # Table of longitude values 131 self.latitude = None # Table of latitude values 132 self.imgbuf = None # Image is rendered to this RGB buffer 133 self.spin_time = 10.0 134 self.chain_length = 6 135 136 # pylint: disable=too-many-branches, too-many-statements 137 def setup(self): 138 """ Returns False on success, True on error """ 139 parser = argparse.ArgumentParser() 140 141 # RGB matrix standards 142 parser.add_argument( 143 "-r", 144 "--led-rows", 145 action="store", 146 help="Display rows. 32 for 32x32, 64 for 64x64. Default: 64", 147 default=64, 148 type=int, 149 ) 150 parser.add_argument( 151 "--led-cols", 152 action="store", 153 help="Panel columns. Typically 32 or 64. (Default: 64)", 154 default=64, 155 type=int, 156 ) 157 parser.add_argument( 158 "-c", 159 "--led-chain", 160 action="store", 161 help="Daisy-chained boards. Default: 6.", 162 default=6, 163 type=int, 164 ) 165 parser.add_argument( 166 "-P", 167 "--led-parallel", 168 action="store", 169 help="For Plus-models or RPi2: parallel chains. 1..3. Default: 1", 170 default=1, 171 type=int, 172 ) 173 parser.add_argument( 174 "-p", 175 "--led-pwm-bits", 176 action="store", 177 help="Bits used for PWM. Something between 1..11. Default: 11", 178 default=11, 179 type=int, 180 ) 181 parser.add_argument( 182 "-b", 183 "--led-brightness", 184 action="store", 185 help="Sets brightness level. Default: 100. Range: 1..100", 186 default=100, 187 type=int, 188 ) 189 parser.add_argument( 190 "-m", 191 "--led-gpio-mapping", 192 help="Hardware Mapping: regular, adafruit-hat, adafruit-hat-pwm", 193 choices=["regular", "regular-pi1", "adafruit-hat", "adafruit-hat-pwm"], 194 type=str, 195 ) 196 parser.add_argument( 197 "--led-scan-mode", 198 action="store", 199 help="Progressive or interlaced scan. 0 Progressive, 1 Interlaced (default)", 200 default=1, 201 choices=range(2), 202 type=int, 203 ) 204 parser.add_argument( 205 "--led-pwm-lsb-nanoseconds", 206 action="store", 207 help="Base time-unit for the on-time in the lowest " 208 "significant bit in nanoseconds. Default: 130", 209 default=130, 210 type=int, 211 ) 212 parser.add_argument( 213 "--led-show-refresh", 214 action="store_true", 215 help="Shows the current refresh rate of the LED panel", 216 ) 217 parser.add_argument( 218 "--led-slowdown-gpio", 219 action="store", 220 help="Slow down writing to GPIO. Range: 0..4. Default: 3", 221 default=4, # For Pi 4 w/6 matrices 222 type=int, 223 ) 224 parser.add_argument( 225 "--led-no-hardware-pulse", 226 action="store", 227 help="Don't use hardware pin-pulse generation", 228 ) 229 parser.add_argument( 230 "--led-rgb-sequence", 231 action="store", 232 help="Switch if your matrix has led colors swapped. Default: RGB", 233 default="RGB", 234 type=str, 235 ) 236 parser.add_argument( 237 "--led-pixel-mapper", 238 action="store", 239 help='Apply pixel mappers. e.g "Rotate:90"', 240 default="", 241 type=str, 242 ) 243 parser.add_argument( 244 "--led-row-addr-type", 245 action="store", 246 help="0 = default; 1=AB-addressed panels; 2=row direct; " 247 "3=ABC-addressed panels; 4 = ABC Shift + DE direct", 248 default=0, 249 type=int, 250 choices=[0, 1, 2, 3, 4], 251 ) 252 parser.add_argument( 253 "--led-multiplexing", 254 action="store", 255 help="Multiplexing type: 0=direct; 1=strip; 2=checker; 3=spiral; " 256 "4=ZStripe; 5=ZnMirrorZStripe; 6=coreman; 7=Kaler2Scan; " 257 "8=ZStripeUneven... (Default: 0)", 258 default=0, 259 type=int, 260 ) 261 parser.add_argument( 262 "--led-panel-type", 263 action="store", 264 help="Needed to initialize special panels. Supported: 'FM6126A'", 265 default="", 266 type=str, 267 ) 268 parser.add_argument( 269 "--led-no-drop-privs", 270 dest="drop_privileges", 271 help="Don't drop privileges from 'root' after initializing the hardware.", 272 action="store_false", 273 ) 274 275 # Extra args unique to this program 276 parser.add_argument( 277 "-i", 278 action="store", 279 help="Image filename for texture map. Default: maps/earth.jpg", 280 default="maps/earth.jpg", 281 type=str, 282 ) 283 parser.add_argument( 284 "-v", 285 dest="pointy", 286 help="Orient cube with vertices at top & bottom.", 287 action="store_true", 288 ) 289 parser.add_argument( 290 "-s", 291 action="store", 292 help="Spin time in seconds/revolution. Default: 10.0", 293 default=10.0, 294 type=float, 295 ) 296 parser.add_argument( 297 "-a", 298 action="store", 299 help="Antialiasing samples/axis. Default: 1", 300 default=1, 301 type=int, 302 ) 303 parser.add_argument( 304 "-t", 305 action="store", 306 help="Run time in seconds. Default: run indefinitely", 307 default=-1.0, 308 type=float, 309 ) 310 parser.add_argument( 311 "-f", 312 action="store", 313 help="Fade in/out time in seconds. Default: 0.0", 314 default=0.0, 315 type=float, 316 ) 317 parser.add_argument( 318 "-e", 319 action="store", 320 help="Edge-to-edge measure of matrix.", 321 default=1.0, 322 type=float, 323 ) 324 parser.add_argument( 325 "-E", 326 action="store", 327 help="Edge-to-edge measure of opposite cube faces.", 328 default=1.0, 329 type=float, 330 ) 331 332 parser.set_defaults(drop_privileges=True) 333 parser.set_defaults(pointy=False) 334 335 args = parser.parse_args() 336 337 if args.led_rows != args.led_cols: 338 print( 339 os.path.basename(__file__) + ": error: led rows and columns must match" 340 ) 341 return True 342 343 if args.led_chain * args.led_parallel != 6: 344 print( 345 os.path.basename(__file__) 346 + ": error: total chained * parallel matrices must equal 6" 347 ) 348 return True 349 350 options = RGBMatrixOptions() 351 352 if args.led_gpio_mapping is not None: 353 options.hardware_mapping = args.led_gpio_mapping 354 options.rows = args.led_rows 355 options.cols = args.led_cols 356 options.chain_length = args.led_chain 357 options.parallel = args.led_parallel 358 options.row_address_type = args.led_row_addr_type 359 options.multiplexing = args.led_multiplexing 360 options.pwm_bits = args.led_pwm_bits 361 options.brightness = args.led_brightness 362 options.pwm_lsb_nanoseconds = args.led_pwm_lsb_nanoseconds 363 options.led_rgb_sequence = args.led_rgb_sequence 364 options.pixel_mapper_config = args.led_pixel_mapper 365 options.panel_type = args.led_panel_type 366 367 if args.led_show_refresh: 368 options.show_refresh_rate = 1 369 370 if args.led_slowdown_gpio is not None: 371 options.gpio_slowdown = args.led_slowdown_gpio 372 if args.led_no_hardware_pulse: 373 options.disable_hardware_pulsing = True 374 if not args.drop_privileges: 375 options.drop_privileges = False 376 377 self.matrix = RGBMatrix(options=options) 378 self.canvas = self.matrix.CreateFrameCanvas() 379 self.matrix_size = args.led_rows 380 self.chain_length = args.led_chain 381 self.max_brightness = args.led_brightness 382 self.run_time = args.t 383 self.fade_time = args.f 384 self.samples_per_pixel = args.a * args.a 385 matrix_measure = args.e 386 cube_measure = args.E 387 self.spin_time = args.s 388 389 try: 390 image = Image.open(args.i) 391 except FileNotFoundError: 392 print( 393 os.path.basename(__file__) 394 + ": error: image file " 395 + args.i 396 + " not found" 397 ) 398 return True 399 400 self.map_width = image.size[0] 401 map_height = image.size[1] 402 self.map_data = image.tobytes() 403 404 # Longitude and latitude tables are 1-dimensional, 405 # can do that because we iterate every pixel every frame. 406 pixels = self.matrix.width * self.matrix.height 407 subpixels = pixels * self.samples_per_pixel 408 self.longitude = [0.0 for _ in range(subpixels)] 409 self.latitude = [0 for _ in range(subpixels)] 410 # imgbuf holds result for one face of cube 411 self.imgbuf = bytearray(self.matrix_size * self.matrix_size * 3) 412 413 coords = POINTY_COORDS if args.pointy else SQUARE_COORDS 414 415 # Fill the longitude & latitude tables, one per subpixel. 416 ll_index = 0 # Index into longitude[] and latitude[] arrays 417 ratio = matrix_measure / cube_measure # Scale ratio 418 offset = ((1.0 - ratio) + ratio / (self.matrix_size * args.a)) * 0.5 419 # Axis offset 420 for face in range(6): 421 upper_left = coords[VERTS[face][0]] 422 upper_right = coords[VERTS[face][1]] 423 lower_left = coords[VERTS[face][2]] 424 for ypix in range(self.matrix_size): # For each pixel Y... 425 for xpix in range(self.matrix_size): # For each pixel X... 426 for yaa in range(args.a): # " antialiased sample Y... 427 yfactor = offset + ratio * (ypix * args.a + yaa) / ( 428 self.matrix_size * args.a 429 ) 430 for xaa in range(args.a): # " antialiased sample X... 431 xfactor = offset + ratio * (xpix * args.a + xaa) / ( 432 self.matrix_size * args.a 433 ) 434 # Figure out the pixel's 3D position in space... 435 x3d = ( 436 upper_left[0] 437 + (lower_left[0] - upper_left[0]) * yfactor 438 + (upper_right[0] - upper_left[0]) * xfactor 439 ) 440 y3d = ( 441 upper_left[1] 442 + (lower_left[1] - upper_left[1]) * yfactor 443 + (upper_right[1] - upper_left[1]) * xfactor 444 ) 445 z3d = ( 446 upper_left[2] 447 + (lower_left[2] - upper_left[2]) * yfactor 448 + (upper_right[2] - upper_left[2]) * xfactor 449 ) 450 # Then convert to polar coords on a sphere... 451 self.longitude[ll_index] = ( 452 (math.pi + math.atan2(-z3d, x3d)) 453 / (math.pi * 2.0) 454 * self.map_width 455 ) % self.map_width 456 self.latitude[ll_index] = int( 457 ( 458 math.pi * 0.5 459 - math.atan2(y3d, math.sqrt(x3d * x3d + z3d * z3d)) 460 ) 461 / math.pi 462 * map_height 463 ) 464 ll_index += 1 465 466 return False 467 468 def run(self): 469 """Main loop.""" 470 start_time, frames = time.monotonic(), 0 471 472 while True: 473 elapsed = time.monotonic() - start_time 474 if self.run_time > 0: # Handle fade in / fade out 475 if elapsed >= self.run_time: 476 break 477 if elapsed < self.fade_time: 478 self.matrix.brightness = int( 479 self.max_brightness * elapsed / self.fade_time 480 ) 481 elif elapsed > (self.run_time - self.fade_time): 482 self.matrix.brightness = int( 483 self.max_brightness * (self.run_time - elapsed) / self.fade_time 484 ) 485 else: 486 self.matrix.brightness = self.max_brightness 487 488 loffset = ( 489 (elapsed % abs(self.spin_time)) / abs(self.spin_time) * self.map_width 490 ) 491 if self.spin_time > 0: 492 loffset = self.map_width - loffset 493 self.render(loffset) 494 495 # Swap double-buffered canvas, show frames per second 496 self.canvas = self.matrix.SwapOnVSync(self.canvas) 497 frames += 1 498 print(frames / (time.monotonic() - start_time)) 499 500 # pylint: disable=too-many-locals 501 def render(self, loffset): 502 """Render one frame of the globe animation, taking latitude offset 503 as input.""" 504 # Certain instance variables (ones referenced in inner loop) are 505 # copied to locals to speed up access. This is kind of a jerk thing 506 # to do and not "Pythonic," but anything for a boost in this code. 507 imgbuf = self.imgbuf 508 map_data = self.map_data 509 lon = self.longitude 510 lat = self.latitude 511 samples = self.samples_per_pixel 512 map_width = self.map_width 513 ll_index = 0 # Index into longitude/latitude tables 514 for face in range(6): 515 img_index = 0 # Index into imgbuf[] 516 for _ in range(self.matrix_size * self.matrix_size): 517 red = green = blue = 0 518 for _ in range(samples): 519 map_index = ( 520 lat[ll_index] * map_width 521 + (int(lon[ll_index] + loffset) % map_width) 522 ) * 3 523 red += map_data[map_index] 524 green += map_data[map_index + 1] 525 blue += map_data[map_index + 2] 526 ll_index += 1 527 imgbuf[img_index] = red // samples 528 imgbuf[img_index + 1] = green // samples 529 imgbuf[img_index + 2] = blue // samples 530 img_index += 3 531 image = Image.frombuffer( 532 "RGB", 533 (self.matrix_size, self.matrix_size), 534 bytes(imgbuf), 535 "raw", 536 "RGB", 537 0, 538 1, 539 ) 540 # Upper-left corner of face in canvas space: 541 xoffset = (face % self.chain_length) * self.matrix_size 542 yoffset = (face // self.chain_length) * self.matrix_size 543 self.canvas.SetImage(image, offset_x=xoffset, offset_y=yoffset) 544 545 546 # pylint: disable=superfluous-parens 547 if __name__ == "__main__": 548 globe = Globe() 549 if not (status := globe.setup()): 550 try: 551 print("Press CTRL-C to stop") 552 globe.run() 553 except KeyboardInterrupt: 554 print("Exiting\n") 555 sys.exit(status)