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