I’ve always been fascinated by LED screens and seeing beautiful colours mix and merge to create vibrant displays. I also love games, so what better way to combine the two than a giant LED pixel matrix which can play games, display audio levels, really anything you can think of.
It uses a Raspberry Pi to controller strips of digital LEDs mounted inside a pixel grid with a diffuser mounted on top. It requires some basic woodworking skills, soldering, and knowledge of Raspberry Pi.
This is a fairly in-depth project build, and as such, the write-up is a bit lengthy, so I’ve broken each section up so you can jump to each stage of the project:
- Designing and Printing the Grid
- Constructing the Frame
- Wiring and Circuitry
- Cleaning Up the Grid and Mounting the Diffuser
- Code
*As I create more games/programs to run, I’ll keep adding them to this project page for everyone to download/use.
Parts Used:
- Raspberry Pi 3
- 15x 1m APA102 (hardware SPI, comes in 5m rolls so you only need 3 5m rolls) or WS2812 (bit-banged) LED 60/m strips (I used the APA102s, so you'll need to modify the code if you use WS2812s)
- General purpose hookup wire
- Heavy duty hookup wire (inner cables from spare extension leads or IEC cables work well)
- Paper vellum (tracing paper also works well)
- Custom sized cut acrylic sheet
- 3-pin JST connectors
- 5x Dean connectors
- 5x 10A 5V power supplies
Tools and Other Materials Required:
- Soldering iron
- 3D printer (I used our Lulzbot Taz 6 printers)
- Woodworking tools (drop saw, drill etc…)
- High-strength epoxy (commonly known as the brand name Araldite)
- Cyanoacrylate (super glue)
- Timber screws of varying sizes
Designing and Printing the Grid
Modelling the baffle
The first step was to create a matrix for separating the pixels. Using non-diffused LEDs is possible for lighting panels, but only with extremely dense LED clusters to create a useable image. I wanted my panel to be big and retro, so some nice chunky pixels were in order. Having the luxury of multiple large format printers available meant that printing them was my first choice. Each pixel (including the pixel frame) is 33mm square, which works nicely with 30 LEDs/m strips. Each part of the frame is 2mm thick, so the pixel ends up only being 31mm in the lit area. Whilst creating mechanical clips for all of the panels would have been possible, I just wanted a simple way to join them and glue/screw together later.
There was no ‘one-size fits all’ tiling pattern available, so I created a couple of different patterns to allow them all to fit together nicely. It also means that someone can take the design and print out more panels to create a bigger matrix if they want.
To make things as easy as possible, I went with a 1m x 0.5m grid, which would allow for 1m strips to make up the rows.
(pictures of models)
Testing for light bleed
After printing a couple of the corner pieces in white, I realised that I should’ve checked for any light bleed between the pixels, so I got testing. It turns out that my baffle model worked well, and almost no light escaped on the top or bottom of the pixels, however, even with a solid 2mm thick wall, the white filament allowed too much light through and you can see a noticeable bleed between the pixels:
Using a black or grey filament eliminated this problem, so I went with grey as I had a bunch of it on hand. I also increased the baffle height to be 20mm instead of 15mm to allow for better dispersion of the light, creating a more even diffusion.
Adding screw tabs
After printing out all the baffles, I realised that I had forgotten to add some anchor points to bolt it into the frame. Not to worry, I just designed and printed some tabs which slotted perfectly between a pixel (on the bottom edge to avoid blocking any light). Some 2-part epoxy and they were rock solid. The entire baffle gets epoxied to the backing frame as well, but having some hardware to help with that mounting is also good.
Constructing the Frame
Using miter joins for frame
Before I assembled the baffle as one whole piece, I wanted to make the frame first so I could ensure a snug fit. Using some 19mm x 42mm pine and some miter joins, I built a simple frame, and then fixed a piece of thick, marine ply to the back. Rather than have all of the wiring exposed on the back panel, I planned to mount a second piece of backing play on top of a small frame to cover the first piece. This can be used to hide any wiring, and also mount the power supplies and board.
Gluing baffle sections together
With my frame completed, I did a second test fit of all the baffle pieces and everything was looking good. After an afternoon and next morning of epoxying the entire baffle together, it became one whole piece which mounted perfectly into the frame with less than 1mm of wiggle room inside the frame. I used Araldite brand epoxy which worked well as it has a 5min set time, but does require 8 hours to fully cure. It is rated at 75KG when fully cured though, so that’s pretty awesome.
Screwing entire baffle into the frame
I used 6x M3 bolts to secure the baffle to the outer frame. This held it in enough to move it around and work on the wiring, without making it to permanent. I ended up removing these once everything was in place as they were no longer required. I then used black silicon and more epoxy to secure everything into place after the wiring was completed.
Adding Backing Panel
The last step to complete the main structure of the display was to add a sheet of 12mm marine grade ply to the back, screwed into the frame, with holes drilled out to run power and data out to the main circuit.
Wiring and Circuitry
Wiring up the LEDs
To make the wiring as neat as possible, I arranged the direction of the grid in a zig-zag pattern starting with 0,0 in a corner. Whilst it makes the code handling a bit trickier, having the wiring as neat as possible was a higher priority, as things can always be modified in code.
Because Dotstar LEDs use SPI, Clock and Data lines are needed and it was the first time I’d wired up that many LEDs in series (15m all up!). After putting in all the SPI wiring, I daisy chained 5V to test the LEDs one by one. The only issue I encountered was a flickering, glitchy behavior on the last few rows of LEDs which I assumed was due to the way I was temporarily running power (it wasn’t, but more on that further on!).
Using multiple power supplies
Something to note about this product is that it requires a fair amount of power. If every LED was lit up at 100% brightness it would draw just over 50 Amps! Fortunately, there are very few cases where you would ever need to draw that much current, however, I used 5 Adafruit 5V 10A power supplies to feed the beast along with some soft limiting in the code, just to allow for maximum fun.
With the Clock and Data lines tested and verified, and all of the LEDs successfully addressed (with the exception of the random glitch behavior), I wired up all 5 power supplies, one for every 3 strips which ensured adequate power for all of the LEDs. The grounds are all connected together, but each 5V line is separated.
Wiring up to Raspberry Pi GPIO
The digital wiring for the Pi is incredibly simple, all I needed to connect was the clock and data lines to the SPI CLK and MOSI pins on the Pi, as well as ground, and I used a spare spot on the power board for a USB port to power the Pi.
Complete rear panel wiring and hanging
With the power supplies mounted, I installed a 6 space power board, ran all of the wires into looms through the panel, and also mounted two hardware strips to run heavy bolts with lock nuts to create a wire hanger for mounting.
Cleaning Up the Grid and Mounting the Diffuser
Mounting paper vellum diffuser
LEDs output bright, focused light, and that’s great for some things, but not for illuminating a giant pixel. To soften the light, I used vellum paper (very similar to tracing paper at art stores) to diffuse each pixel and create a soft, uniform glow. One sheet wasn’t quite enough to get the desired effect, so I used two.
I used some thin, double-sided foam tape on the frame to help mount the vellum paper, before stapling both sheets down around the edge and cutting them down to size.
Mounting acrylic panel
Whilst the vellum paper looked great, it’s fairly easy to tear, and almost impossible to get completely taut so it makes good contact with every part of the grid. So, to create a nice, polished surface, I used a sheet of 4.5mm acrylic, cut down to the size of the frame. I cut out 10 holes for mounting bolts using our new Trotec laser and then used those for guides to drill through the whole frame. I ended up removing the screws I used to mount the backing board to the frame, as they weren’t necessary with the new bolts, and because I had measured the holes for the bolts in the exact same place on the frame (oops!).
The acrylic panel made it look amazing when lit, but the outer edge of the frame wasn’t as clean as I would’ve liked it, so I carefully used some gloss black spray paint to create a nice border.
The only thing I would do differently is to drill the holes for the bolts in the wood before mounting the vellum as when drilling it all together, fine wood chips became lodged in between the layers of vellum. But alas, the construction is finished!
Code
All of the scripts and info required to build your own LED matrix (with some alteration required if you use different dimensions or controllers etc...) can be found on the LED Pixel Panel Github repo.
As I mentioned earlier, there was a strange glitching artifact which occurred towards the end of the signal run. It turned out to be an issue with the line capacitance which played havoc with the high-speed SPI signals, so all that was needed was to decrease the SPI clock speed to 8MHz which is still very fast.
I've posted the scripts for the VU meter and Snake below, however, whilst those scripts will run standalone with the required python modules installed, the project is designed to run on startup, and uses Bluetooth Keyboard inputs to switch between the scripts that are running, which are taken care of by the launcher.sh and handler-script.py files.
Snake
I based my code off a demo written by Paul Brown who published a great demonstration of his snake game which can be played on a Pimoroni Unicorn HAT. After picking through it line by line to understand the mechanics of it, I went to work modifying it to work with an 8Bitdo Bluetooth controller. Pygame actually made it easier than I anticipated as the 8Bitdo controllers have a mode to operate as a standard keyboard.
I tweaked it and added some different event handling and animations, but kept it on the Unicorn HAT form factor. I actually got started on this while I was waiting for my baffle prints to finish, so I didn’t have the full matrix to test it with.
Once I had the basic mechanics working as I wanted them to (I added some end game animations and event handling, plus integrated controller mapping and some other bits and pieces), I modified the code to work with the pixel matrix. It was actually fairly easy as the Unicorn HAT uses WS2812 pixels, which, whilst being functionally different to the APA102 LEDs I used, the library handlers are almost identical (the APA102s are much faster though as they use hardware SPI, not bit-banging protocol).
from dotstar import Adafruit_DotStar from random import randint import time, pygame #button mapping up = 46 down = 32 left = 18 right = 33 start = 24 sizeX = 30 sizeY = 15 NUMPIXELS = 2*(sizeX*sizeY) order = 'gbr' SPEED_START = 10 SPEED = SPEED_START strip = Adafruit_DotStar(NUMPIXELS, 12000000, order=order) # -- CONSTANTS #screen size, used for pygame input events and to display instructions SCREEN_WIDTH = 6 SCREEN_HEIGHT = 4 #colours r g b BLACK = ( 0, 0, 0) WHITE = ( 255, 255, 255) GREEN = ( 0, 255, 0) RED = ( 255, 0, 0) BLUE = ( 0, 0, 255) YELLOW =(255, 255, 0) PURPLE = (255, 0, 255) CYAN = ( 0, 255, 255) COLOURS = (GREEN, RED, BLUE, YELLOW, PURPLE, CYAN) keyPressed = 0 #grid mapping handler pixelGrid = [[0 for x in range(sizeX 1)] for y in range(sizeY 1)] keyVal = 840 for j in range(sizeY): for i in range(sizeX): if(j % 2 == 0): rawVal = keyVal (i*2) else: rawVal = keyVal - (i*2) pixelGrid[j][i] = rawVal if(j % 2 == 0): keyVal = keyVal - 2 else: keyVal = keyVal - 118 strip.begin() strip.setBrightness(255) # --- CLASSES #snake class for player class Snake(): #attributes body_list = None #snake segment locations change_x = None #movement on x-axis change_y = None #movement on y-axis eaten = None #has the snake eaten some food? g_over = False #holds value for game over if snake goes off the screen or eats itself #methods def __init__(self): self.body_list = [[14,10],[14,11]] #starting location self.change_x = 1 self.change_y = 0 self.eaten = False def update(self, food): #remove old segment old_segment = self.body_list.pop() self.eaten = False #find new segment x = self.body_list[0][0] self.change_x y = self.body_list[0][1] self.change_y if x == sizeX: x = 0 if x < 0: x = sizeX-1 if y == sizeY: y = 0 if y < 0: y = sizeY-1 segment = [x,y] self.body_list.insert(0, segment) #check for eaten food if segment[0] == food.x_pos and segment[1] == food.y_pos: self.body_list.append(old_segment) self.eaten = True global SPEED SPEED = 0.4 else: strip.setPixelColor(pixelGrid[old_segment[1]][old_segment[0]],BLACK[0],BLACK[1],BLACK[2]) strip.setPixelColor(pixelGrid[old_segment[1]][old_segment[0]] 1,BLACK[0],BLACK[1],BLACK[2]) #prepare segments for display on unicorn hat, use try to prevent exception from crashing the game for segment in self.body_list: try: strip.setPixelColor(pixelGrid[segment[1]][segment[0]],WHITE[0],WHITE[1],WHITE[2]) strip.setPixelColor(pixelGrid[segment[1]][segment[0]] 1,WHITE[0],WHITE[1],WHITE[2]) except: self.g_over = True #movement controls def go_left(self): self.change_x = -1 self.change_y = 0 def go_right(self): self.change_x = 1 self.change_y = 0 def go_up(self): self.change_x = 0 self.change_y = 1 def go_down(self): self.change_x = 0 self.change_y = -1 #check for game over, returned to game class def game_over(self): #game over events if self.body_list[0] in self.body_list[1::] or self.g_over == True: self.game_over_sequence() return True #if self.body_list[0][0] > sizeX or self.body_list[0][0] < 0 or self.body_list[0][1] > sizeY or self.body_list[0][1] < 0: #self.game_over_sequence() #return True return False def game_over_sequence(self): for segment in self.body_list: strip.setPixelColor(pixelGrid[segment[1]][segment[0]], 255,50,50) strip.setPixelColor(pixelGrid[segment[1]][segment[0]] 1, 255,50,50) strip.show() game = Game() game.process_events() #Class for food class Food(): #attributes eaten = None x_pos = None y_pos = None r = None g = None b = None #methods def __init__(self): self.eaten = True #set to true so that it is reset at start def update(self, snake): if self.eaten: #inside checks to ensure food isn't draw in the same location as the snakes body inside = True while inside: self.x_pos = randint(0,(sizeX-1)) self.y_pos = randint(0,(sizeY-1)) if [self.x_pos, self.y_pos] in snake.body_list: inside = True if self.x_pos == 0 and self.y_pos == 14: inside = True else: inside = False #give food a random colour from list of colours. List ensures visible strong colours colour = randint(0,len(COLOURS)-1) self.r = COLOURS[colour][0] self.g = COLOURS[colour][1] self.b = COLOURS[colour][2] #prepare for display on unicorn hat strip.setPixelColor(pixelGrid[self.y_pos][self.x_pos],self.r,self.g,self.b) strip.setPixelColor(pixelGrid[self.y_pos][self.x_pos] 1,self.r,self.g,self.b) self.eaten = False #game class class Game(object): #attributes snake = None food = None game_over = None start = False #used to start the game in the same conditions as game over #methods def __init__(self): self.snake = Snake() self.food = Food() def process_events(self): for event in pygame.event.get(): if event.type == pygame.QUIT: return True #exits main game loop if event.type == pygame.KEYDOWN and event.key == pygame.K_o: #start or restart the game global SPEED strip.clear() strip.show() SPEED = SPEED_START self.__init__() self.start = False if event.type == pygame.KEYDOWN: #movement if event.key == pygame.K_n: strip.clear() strip.show() exit() global keyPressed if event.key == pygame.K_e and self.snake.change_x != 1 and keyPressed == 0: keyPressed = 1 self.snake.go_left() elif event.key == pygame.K_f and self.snake.change_x != -1 and keyPressed == 0: keyPressed = 1 self.snake.go_right() elif event.key == pygame.K_c and self.snake.change_y != -1 and keyPressed == 0: keyPressed = 1 self.snake.go_up() elif event.key == pygame.K_d and self.snake.change_y != 1 and keyPressed == 0: keyPressed = 1 self.snake.go_down() return False #stay in main game loop def run_logic(self): #check for game over first self.game_over = self.snake.game_over() #only if it is not the first game and not game over update the food and snake if not self.game_over and not self.start: self.food.update(self.snake) self.snake.update(self.food) self.food.eaten = self.snake.eaten def display_frame(self, screen): #screen used to display information to the player, also required to detect pygame events #Unicorn hat display is also handled here if self.game_over or self.start: strip.clear() # removes last snake image from unicorn hat else: strip.show() # display the snake and food on the unicorn hat pygame.display.flip() #main game function and loop def main(): global keyPressed pygame.init() size = (SCREEN_WIDTH, SCREEN_HEIGHT) screen = pygame.display.set_mode(size) done = False clock = pygame.time.Clock() game = Game() while not done: try: done = game.process_events() game.run_logic() game.display_frame(screen) keyPressed = 0 clock.tick(SPEED) except KeyboardInterrupt: print("Ctrl-C Terminating...") strip.clear() strip.show() sys.exit(1) pygame.quit() if __name__ == '__main__': main()
Spectrum Analyser
This was a fun script to write as it involved learning about some new concepts such as Fourier transforms and other cool maths, however, I left the heavy lifting to the brilliant minds who wrote modules such as ‘numpy’ (number-py). Using a USB microphone, I worked from the basic example shared on the fantastic Rotorton website and then modified it to suit my project.
#!usr/bin/python import sys import pyaudio from struct import unpack import numpy as np from dotstar import Adafruit_DotStar import os import time toggleDir = '/home/pi/SnakeMatrix/toggleYes' NUMPIXELS = 900 ORDER = 'gbr' strip = Adafruit_DotStar(NUMPIXELS, 8000000, order=ORDER) strip.begin() strip.show() #setup grid mapping for matrix display sizeX = 30 sizeY = 15 pixelGrid = [[0 for x in range(sizeX)] for y in range(sizeY)] keyVal = 840 for j in range(sizeY): for i in range(sizeX): if(j % 2 == 0): rawVal = keyVal (i*2) else: rawVal = keyVal - (i*2) pixelGrid[j][i] = rawVal if(j % 2 == 0): keyVal = keyVal - 2 else: keyVal = keyVal - 118 colourGrad = [0xff0000,0xff0034,0xff0069,0xff009e,0xff00d2,0xf600ff,0xc100ff,0x8c00ff,0x5800ff,0x2300ff, 0x0011ff,0x0045ff,0x007aff,0x00afff,0x00E4ff,0x00ffE5,0x00ffb0,0x00ff7b,0x00ff46,0x00ff12, 0x22ff00,0x57ff00,0x8bff00,0xc0ff00,0xf5ff00,0xffd300,0xff9f00,0xff6a00,0xff3500,0xff0100] matrix = [0 for x in range(30)] power = [] weighting = [1,1,1,1,2,2,2,2,4,4,4,4,8,8,8,8,16,16,16,16,32,32,32,32,64,64,64,64,128,128] device = 0 yeti = 'Yeti Stereo Microphone' usbMic = 'USB PnP Sound Device' def list_devices(): #microphone profiles global yeti global usbMic global device # List all audio input devices p = pyaudio.PyAudio() i = 0 n = p.get_device_count() while i < n: dev = p.get_device_info_by_index(i) if dev['maxInputChannels'] > 0: print(str(i) '. ' dev['name']) if yeti in (dev['name']): device = i return yeti elif usbMic in (dev['name']): device = i return usbMic i = 1 # Audio setup no_channels = 1 sample_rate = 44100 # Chunk must be a multiple of 8 # NOTE: If chunk size is too small the program will crash # with error message: [Errno Input overflowed] chunk = 4096 micProfile = list_devices() #frequency response scaling for each microphone if micProfile == usbMic: senseVal = 20 bassAdj = 1*senseVal midAdj = 1*senseVal trebleAdj = 1*senseVal elif micProfile == yeti: senseVal = 10 bassAdj = 1*senseVal midAdj = 1*senseVal trebleAdj = 1*senseVal p = pyaudio.PyAudio() stream = p.open(format = pyaudio.paInt16, channels = no_channels, rate = sample_rate, input = True, frames_per_buffer = chunk, input_device_index = device) # Return power array index corresponding to a particular frequency def piff(val): return int(2*chunk*val/sample_rate) def calculate_levels(data, chunk,sample_rate): global matrix # Convert raw data (ASCII string) to numpy array data = unpack("%dh"%(len(data)/2),data) data = np.array(data, dtype='h') # Apply FFT - real data fourier=np.fft.rfft(data) # Remove last element in array to make it the same size as chunk fourier=np.delete(fourier,len(fourier)-1) # Find average 'amplitude' for specific frequency ranges in Hz power = np.abs(fourier) matrix[0]= int(np.mean(power[piff(0) :piff(22):1]))*bassAdj matrix[1]= int(np.mean(power[piff(22) :piff(28):1]))*bassAdj matrix[2]= int(np.mean(power[piff(28) :piff(36):1]))*bassAdj matrix[3]= int(np.mean(power[piff(36) :piff(44):1]))*bassAdj matrix[4]= int(np.mean(power[piff(44) :piff(56):1]))*bassAdj matrix[5]= int(np.mean(power[piff(56) :piff(70):1]))*bassAdj matrix[6]= int(np.mean(power[piff(70) :piff(89):1]))*midAdj matrix[7]= int(np.mean(power[piff(89) :piff(112):1]))*midAdj matrix[8]= int(np.mean(power[piff(112) :piff(141):1]))*midAdj matrix[9]= int(np.mean(power[piff(140) :piff(180):1]))*midAdj matrix[10]= int(np.mean(power[piff(178):piff(224):1]))*midAdj matrix[11]= int(np.mean(power[piff(224):piff(282):1]))*midAdj matrix[12]= int(np.mean(power[piff(282):piff(355):1]))*midAdj matrix[13]= int(np.mean(power[piff(355):piff(447):1]))*midAdj matrix[14]= int(np.mean(power[piff(447):piff(562):1]))*midAdj matrix[15]= int(np.mean(power[piff(562):piff(622):1]))*midAdj matrix[16]= int(np.mean(power[piff(622):piff(708):1]))*midAdj matrix[17]= int(np.mean(power[piff(708):piff(788):1]))*midAdj matrix[18]= int(np.mean(power[piff(788):piff(891):1]))*midAdj matrix[19]= int(np.mean(power[piff(891):piff(1122):1]))*midAdj matrix[20]= int(np.mean(power[piff(1122):piff(1413):1]))*midAdj matrix[21]= int(np.mean(power[piff(1413):piff(1778):1]))*midAdj matrix[22]= int(np.mean(power[piff(1778):piff(2239):1]))*midAdj matrix[23]= int(np.mean(power[piff(2239):piff(2818):1]))*trebleAdj matrix[24]= int(np.mean(power[piff(2818):piff(3548):1]))*trebleAdj matrix[25]= int(np.mean(power[piff(3548):piff(4467):1]))*trebleAdj matrix[26]= int(np.mean(power[piff(4467):piff(5623):1]))*trebleAdj matrix[27]= int(np.mean(power[piff(5623):piff(7079):1]))*trebleAdj matrix[28]= int(np.mean(power[piff(7079):piff(8913):1]))*trebleAdj matrix[29]= int(np.mean(power[piff(8913):piff(11220):1]))*trebleAdj # Tidy up column values for the LED matrix matrix=np.divide(np.multiply(matrix,weighting),1000000) # Set floor at 0 and ceiling at 8 for LED matrix matrix=matrix.clip(0,sizeY) return matrix # Main loop while 1: try: # Get microphone data data = stream.read(chunk) matrix=calculate_levels(data, chunk,sample_rate) strip.clear() lastColour = colourGrad[29] colourGrad.pop(29) colourGrad.insert(0, lastColour) for y in range (0,30): for x in range(0, matrix[y]): strip.setPixelColor(pixelGrid[x][y], colourGrad[y]) strip.setPixelColor(pixelGrid[x][y] 1, colourGrad[y]) strip.show() while os.path.exists(toggleDir): sleep(0.01) except KeyboardInterrupt: if os.path.exists(toggleDir): print("path found") os.rmdir(toggleDir) print("Ctrl-C Terminating...") strip.clear() strip.show() stream.stop_stream() stream.close() p.terminate() sys.exit(1) except Exception, e: if os.path.exists(toggleDir): print("path found") os.rmdir(toggleDir) print(e) strip.clear() strip.show() print("ERROR Terminating...") stream.stop_stream() stream.close() p.terminate() sys.exit(1)
Build Your Own
As I mentioned at the start, hopefully, I'll have time to create some more games for this panel, however, with the matrix wrapper that I created, anyone can write scripts for it and simply pixels based on a set of cartesian coordinates where 0,0 is the bottom left corner (or you could modify it based on your layout).
I'd love to see a picture if you've made this project or come up with your own remix, so be sure to post some pictures!