diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a7e108 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Odyssey LED martix challenge introduction +Show us what you can throw together in an hour! You will write code to draw on my LED matrix. +It is a 17x17 matrix built out of WS812b LED pixels, which can be individually addressed and are +able to emit 16 million colours. Your work will be judged by (and only by) the non-coders in the +company, so impress them, not get stuck on your code style! + +# Requirements +The code is written in python 3 and the required library (Pillow) that you need to install is in +the requirements.txt file. This is not necesary, but is used to render text in a helper method. +If you want to avoid installing it, just comment out the relevant code in `renderer_common.py` + +# boilerplate.py +Your starting point, contains the example code. The code will be ran on a raspberry pi that +controls the LED matrix. As you can see, the renderer runs in a separate process, so you will +have an entire raspberry pi 3 processor core to use for your image generation. + +Put your code between the try: except sturcture, everything currently there can be removed. + +## General recommendations: +- It is recommended to use the sleep routine in the boilerplate code for a stable and persistent +frame rate. +- If the code runs poorly, the frame rate can be lowered +- The LED panel is pretty bright, I recommend dimming colours/pixels using the provided, it will come +though better on camera as well. A Brightness between 30 and 60 gives enough flexibility with colours +without being too aggressive. For the full 16 million colours, don't dim, but bear the consequences :) +- The thing runs on a £10 chinesium power supply, avoid turning on all LEDs at with all colours at +full brightness +- If you are animating, it is cheaper in terms of hardware resources, to turn of pixels that are not needed +in the next frame, instead of rendering an empty frame (clear screen) between frames. Sending the LED strip +data through i2c takes time and it may delay the rendering of the next frame causing frame drops. + +# renderer_common.py +This contains constants and functions used by both the renderers and the code using the renderer. +Here you can find the definition of the LED panel, some constants for easier management (clearing) + +`dim_individual_colour` allows the brightness to be applied to just a colour value (red or green for example) + +`dim` will dim a colour tuple or an individual colour (interchangeable) + +Pixels are custom named tuples as defined here Pxels are to be defined by the ID of the LED pixel on the strip +and a colour, which is also a tuple. This provides flexibility for various effects, like the random +pixels in the example. The ID of a led pixel can be resolved from it's coordinates using the `get_pixel_index` function. + +Colours are to be defined as a named tuple defined here. + +# TerminalRenderer.py +This is a helper that allows the drawn picture to be rendered as ASCII characters in the terminal. +It's features are identical to the other renderers that I use live, so if your code runs in the terminal, +it should run on the matrix as well. I tested this renderer on multiple computers on macOS and Windows and +worked just fine, though it may not render perfectly smoothly in the terminal depending on the speed +of your computer. + +# Submitting your code +- Any additional requirements/libraries your code needs, please include in the `requirement.txt` +- Create a pull request with your code +- Make the result pretty, not your code, this is not a coder dick weaving, you need to impress +the non-coders! :) +- Please submit your code an hour before the kickoff at latest, so I have time to deploy, test and +optimise if needed be! \ No newline at end of file diff --git a/TerminalRenderer.py b/TerminalRenderer.py new file mode 100644 index 0000000..42acf8d --- /dev/null +++ b/TerminalRenderer.py @@ -0,0 +1,62 @@ +import multiprocessing +from renderer_common import LED_COUNT, CLEAR_FRAME_INDEX, ROWS, COLUMNS +from traceback import print_exc +import os + + +class TerminalRenderer(multiprocessing.Process): + def __init__(self, render_pipe, stop_signal, stopped_signal, render_delay=None): + super(TerminalRenderer, self).__init__() + + self.render_pipe = render_pipe + self.stop_signal = stop_signal + self.stopped_signal = stopped_signal + self.brightness = 255 + + self.unsuccessful_renders = 0 + self.led_strip = [0] * LED_COUNT + + def run(self): + try: + while not self.stop_signal.is_set(): + if self.render_pipe.poll(0.05): + frame = self.render_pipe.recv() + for pixel in frame: + if pixel.index == CLEAR_FRAME_INDEX: + self.led_strip = [0] * LED_COUNT + else: + if pixel.colour.green == 0x0 and pixel.colour.red == 0x0 and pixel.colour.blue == 0x0: + self.led_strip[pixel.index] = 0 + else: + self.led_strip[pixel.index] = 1 + + self.print_matrix() + + except KeyboardInterrupt: + pass + except Exception: + print("Exception in renderer") + print_exc() + finally: + print("Terminating renderer") + self.stopped_signal.set() + + def print_matrix(self): + os.system('cls' if os.name == 'nt' else 'clear') + frame = "" + for row in range(0, ROWS): + line = "{:2}|".format(row) + for pixel in range(row*COLUMNS, (row+1)*COLUMNS): + line += '*|' if self.led_strip[pixel] == 1 else " |" + frame += line + os.linesep + print(frame) + + +def initialise_renderer(render_delay=None): + render_pipe_out, render_pipe_in = multiprocessing.Pipe(False) + terminate_renderer = multiprocessing.Event() + renderer_terminated = multiprocessing.Event() + renderer = TerminalRenderer(render_pipe_out, terminate_renderer, renderer_terminated, render_delay=None) + renderer.daemon = True + renderer.start() + return renderer, render_pipe_in, terminate_renderer, renderer_terminated diff --git a/UbuntuMono-B.ttf b/UbuntuMono-B.ttf new file mode 100644 index 0000000..7bd6665 Binary files /dev/null and b/UbuntuMono-B.ttf differ diff --git a/boilerplate.py b/boilerplate.py new file mode 100644 index 0000000..b3ccac1 --- /dev/null +++ b/boilerplate.py @@ -0,0 +1,116 @@ +import traceback +import random +import time +import sys +from renderer_common import Colour, Pixel, ROWS, COLUMNS, LED_COUNT, CLEAR_FRAME, dim, draw_rectangle, render_text, get_pixel_index + +# Renderer selection according to argument. By default, it's the terminal renderer, other renderers will be used live. +if "pi" in sys.argv: + from RaspberryPiRenderer import initialise_renderer +elif "nightdriver" in sys.argv: + from NightDriverRenderer import initialise_renderer +else: + from TerminalRenderer import initialise_renderer + + +def get_random_pixel(): + """ + returns a random pixel ID + :return: + """ + return random.randint(0, ROWS * COLUMNS - 1) + + +# Brightness 1 so it doesn't burn my retina +BRIGHTNESS = 1 + +# Framerate in FPS +FRAMERATE = 20 +FRAME_TIME = (60/FRAMERATE)/60 + +# White colour just for the demonstration +COLOUR = Colour(0xFF, 0xFF, 0xFF) + + +if __name__ == "__main__": + + shutdown = False + + # Renderer initialisation + renderer, render_pipe, terminate_renderer, renderer_terminated = initialise_renderer(render_delay=2) + + try: + + # Putting a single red pixel on the LED matrix using x, y coordinates + # Defining the red colour + colour = Colour(0xFF, 0x00, 0x00) + + # Defining the coordinates of the pixel. The top left pixel of the matrix is 0,0 and + # currently it has 17 rows and 17 columns (rows 0-16 and columns 0-16) + x = 3 + y = 13 + + # Defining the actual pixel + pixel = Pixel(get_pixel_index(x, y), colour) + + # Defining a frame, which is a list of pixels. + # A frame (list of pixels) is the unit that can be rendered. + frame = [pixel] + + # Send the frame for rendering + render_pipe.send(frame) + + # Draw two rectangles and wait 3s. Shows that putting a new frame in the render pipe doesn't + # clear the empty pixels in the new frame + render_pipe.send(draw_rectangle(3, 3, 5, 8, dim(COLOUR, BRIGHTNESS))) + render_pipe.send(draw_rectangle(9, 9, 3, 6, dim(COLOUR, BRIGHTNESS), fill=True)) + time.sleep(3) + + # Clearing matrix by putting the pre-defined CLEAR_FRAME into the render pipe + render_pipe.send(CLEAR_FRAME) + + # Render the text "Hello world" on the board + render_text("Hello world", render_pipe, dim(COLOUR, BRIGHTNESS)) + time.sleep(1) + + # Clearing matrix by putting the pre-defined CLEAR_FRAME into the render pipe + render_pipe.send(CLEAR_FRAME) + + # Continuously running random pixel pattern thing + active_pixels = [] + MAX_ACTIVE_PIXELS = 50 + + counter = 0 + while not shutdown: + start = time.time() + pixels_to_render = [] + pixel = get_random_pixel() + while pixel in active_pixels: + pixel = get_random_pixel() + active_pixels.append(pixel) + pixels_to_render.append(Pixel(pixel, dim(COLOUR, BRIGHTNESS))) + if MAX_ACTIVE_PIXELS < len(active_pixels): + turn_off_pixel = active_pixels.pop(0) + pixels_to_render.append(Pixel(turn_off_pixel, Colour(0x00, 0x00, 0x00))) + render_pipe.send(pixels_to_render) + + # Recommended wait between frame renders to maintain a stable FPS and allow overruns + try: + time.sleep(FRAME_TIME - (time.time() - start)) + except Exception: + pass + + print("Terminating") + render_pipe.send(CLEAR_FRAME) + + except KeyboardInterrupt: + pass + except Exception: + print("Exception in main loop") + traceback.print_exc() + finally: + print("Terminating renderer") + terminate_renderer.set() + while not renderer_terminated.is_set(): + time.sleep(0.1) + print("All processes stopped") diff --git a/renderer_common.py b/renderer_common.py new file mode 100644 index 0000000..8739080 --- /dev/null +++ b/renderer_common.py @@ -0,0 +1,140 @@ +from collections import namedtuple +from PIL import Image +from PIL import ImageFont +from PIL import ImageDraw +import time + +# Use this special pixel index to trigger a fast and efficient clear (turn off) of all pixels on the board +CLEAR_FRAME_INDEX = 0XBEEF + +# This is used to define a colour. Colours are integers between 0x00-0xFF (0-255), as per the hex +# colour coding definition. Purple for instance is 0xff00ff -> Colour(0xFF, 0x00, 0xFF) +Colour = namedtuple("Colour", ["red", "green", "blue"]) + +# This is used to define a pixel that is to be inserted into the render queue. +# Index is its position on the matrix (more precisely it's index in the led strip) +# Colour is the colour tuple that contains the hex of the 3 base colours +Pixel = namedtuple("Pixel", ["index", "colour"]) + +# Render this pixel to trigger a fast and efficient clear (turn off) of all pixels on the board +CLEAR_FRAME_PIXEL = Pixel(CLEAR_FRAME_INDEX, None) + +# Render this frame to trigger a fast and efficient clear (turn off) of all pixels on the board +CLEAR_FRAME = [CLEAR_FRAME_PIXEL] + +# Turned off pixel colour +DARK = Colour(0x00, 0x00, 0x00) + +# An example pixel that can be inserted into the render pipe for rendering +EXAMPLE_PIXEL = Pixel(2, Colour(0x00, 0x01, 0x00)) + +# Current LED board size definition +ROWS = 17 +COLUMNS = 17 +LED_COUNT = ROWS * COLUMNS + + +def dim_individual_colour(colour, brightness): + """ + Dims an individual colour + :param colour: int 0-255 + :param brightness: brightness 0-255 + :return: dimmed colour + """ + new_colour = (float(brightness) / 255) * float(colour) + if new_colour < 0: + return 1 + if 255 < new_colour: + return 255 + return int(new_colour) + + +def dim(colour, brightness): + """ + Dims a colour + """ + try: + return Colour(dim_individual_colour(colour.red, brightness), + dim_individual_colour(colour.green, brightness), + dim_individual_colour(colour.blue, brightness)) + except Exception: + try: + return dim_individual_colour(colour, brightness) + except Exception: + return colour + + +def render_text(text, render_pipe_input, text_colour): + """ + Method to render text on the matrix + :param text: The text to be rendered + :param render_pipe_input: The render pipe in which the rendered frames are to be inserted + :param text_colour: The colour to be used when rendering, Colour named tuple + :return: + """ + font = ImageFont.truetype('UbuntuMono-B.ttf', 17) # load the font + size = font.getsize(text) # calc the size of text in pixels + image = Image.new('1', size, 1) # create a b/w image + draw = ImageDraw.Draw(image) + draw.text((0, -1), text, font=font) # render the text to the bitmap + column = 0 + while column + 17 < size[0]: + start = time.time() + render_pixels = [] + for index, column_to_render in enumerate(range(column, column + 17)): + for row_to_render in range(2, size[1]): + if image.getpixel((column_to_render, row_to_render)): + render_pixels.append(Pixel(get_pixel_index(index, row_to_render-1), DARK)) + else: + render_pixels.append(Pixel(get_pixel_index(index, row_to_render-1), text_colour)) + render_pipe_input.send(render_pixels) + if column == 0: + time.sleep(.5) + else: + try: + time.sleep(0.05 - (time.time() - start)) + except Exception: + pass + column += 1 + + +def draw_rectangle(x, y, a, b, colour, fill=False): + """ + Draws a rectangle + :param x: top left corner x coordinate + :param y: top left corner y coordinate + :param a: horizontal side length + :param b: vertical side length + :param colour: the colour of the rectangle + :param fill: fill, true or false + :return: rectangle frame + """ + to_return = [] + if fill: + for i in range(0, a): + for j in range(0, b): + to_return.append(Pixel(get_pixel_index(x+i, y+j), colour)) + else: + for i in range(0, a): + to_return.append(Pixel(get_pixel_index(x+i, y), colour)) + to_return.append(Pixel(get_pixel_index(x+i, y+b-1), colour)) + for j in range(1, b-1): + to_return.append(Pixel(get_pixel_index(x, y+j), colour)) + to_return.append(Pixel(get_pixel_index(x + a-1, y + j), colour)) + return to_return + + +def left_to_right_top_to_bottom(x, y): + """ + Transformation from x/y coordinate to an index of the pixel on the LED strip + :param x: x coordinate + :param y: y coordinate + :return: the index on the LED strip + """ + if y < 0 or x < 0 or ROWS <= y or COLUMNS <= x: + raise ValueError("Invalid coordinate)") + return y * COLUMNS + x + + +# Transformation appropriate for this LED setup +get_pixel_index = left_to_right_top_to_bottom diff --git a/requirement.txt b/requirement.txt new file mode 100644 index 0000000..7e2fba5 --- /dev/null +++ b/requirement.txt @@ -0,0 +1 @@ +Pillow