pythonraspberry-pipygame

Python pygame fails to output to /dev/fb1 on a Raspberry Pi + TFT screen


TL;DR

I am fiddling with a Raspberry Pi 2 and a 2.8" TFT touch screen attached to the Pi's GPIO. The Pi is also connected to a HDMI monitor.
My issue is that my Python3 pygame script is not able to use the TFT screen, but always displays on my HDMI screen instead.

Some background

I've installed the latest vanilla Raspbian ready-to-use distro and followed the TFT screen installation steps, everything works well: the TFT can display the console and X without issue. The touchscreen is calibrated and moves the cursor correctly. I can also see a new framebuffer device as /dev/fb1.

I've tried the following to test this new device:

sudo fbi -T 2 -d /dev/fb1 -noverbose -a my_picture.jpg

=> This successfully displays the pic on the TFT screen

while true; do sudo cat /dev/urandom > /dev/fb1; sleep .01; done

=> This successfully displays statics on the TFT screen

However, when I run this Python3/pygame script, the result appears in the HDMI screen consistently and not on the TFT screen:

#!/usr/bin/python3

import os, pygame, time

def setSDLVariables():
    print("Setting SDL variables...")
    os.environ["SDL_FBDEV"] = "/dev/fb1"
    os.environ["SDL_VIDEODRIVER"] = driver
    print("...done") 

def printSDLVariables():
    print("Checking current env variables...")
    print("SDL_VIDEODRIVER = {0}".format(os.getenv("SDL_VIDEODRIVER")))
    print("SDL_FBDEV = {0}".format(os.getenv("SDL_FBDEV")))

def runHW5():
    print("Running HW5...")
    try:
        pygame.init()
    except pygame.error:
        print("Driver '{0}' failed!".format(driver))
    size = (pygame.display.Info().current_w, pygame.display.Info().current_h)
    print("Detected screen size: {0}".format(size))
    lcd = pygame.display.set_mode(size)
    lcd.fill((10,50,100))
    pygame.display.update()
    time.sleep(sleepTime)
    print("...done")

driver = 'fbcon'
sleepTime= 0.1

printSDLVariables()
setSDLVariables()
printSDLVariables()
runHW5()

The script above runs as follow:

pi@raspberrypi:~/Documents/Python_HW_GUI $ ./hw5-ThorPy-fb1.py
Checking current env variables...
SDL_VIDEODRIVER = None
SDL_FBDEV = None
Setting SDL variables...
...done
Checking current env variables...
SDL_VIDEODRIVER = fbcon
SDL_FBDEV = /dev/fb1
Running HW5...
Detected screen size: (1920, 1080)
...done

I have tried different drivers (fbcon, directfb, svgalib...) without success.

Any help or idea would be greatly appreciated, I've been through a lot of doc, manuals and samples and just ran out of leads :/ Furthermore, it appears that a lot of people have succeeded in getting Python3/pygame to output to their TFT screen via /dev/fb1.


Solution

  • I have been fiddling around that for far too many hours now, but at least I have found what I'd call a decent workaround, if not a solution.

    TL;DR

    I've kept using pygame for building my graphics/GUI, and switched to evdev for handling the TFT touch events. The reason for using evdev rather than pygame's built-in input management (or pymouse, or any other high level stuff) is explained in the next section.

    In a nutshell, this program builds some graphics in memory (RAM, not graphic) using pygame, and pushes the built graphics as bytes into the TFT screen framebuffer directly. This bypasses any driver so it is virtually compatible with any screen accessible through a framebuffer, however it also bypasses any potential optimizations coming along what would be a good driver.

    Here is a code sample that makes the magic happen:

    #!/usr/bin/python3
    
    ##
    # Prerequisites:
    # A Touchscreen properly installed on your system:
    # - a device to output to it, e.g. /dev/fb1
    # - a device to get input from it, e.g. /dev/input/touchscreen
    ##
    
    import pygame, time, evdev, select, math
    
    # Very important: the exact pixel size of the TFT screen must be known so we can build graphics at this exact format
    surfaceSize = (320, 240)
    
    # Note that we don't instantiate any display!
    pygame.init()
    
    # The pygame surface we are going to draw onto. 
    # /!\ It must be the exact same size of the target display /!\
    lcd = pygame.Surface(surfaceSize)
    
    # This is the important bit
    def refresh():
        # We open the TFT screen's framebuffer as a binary file. Note that we will write bytes into it, hence the "wb" operator
        f = open("/dev/fb1","wb")
        # According to the TFT screen specs, it supports only 16bits pixels depth
        # Pygame surfaces use 24bits pixels depth by default, but the surface itself provides a very handy method to convert it.
        # once converted, we write the full byte buffer of the pygame surface into the TFT screen framebuffer like we would in a plain file:
        f.write(lcd.convert(16,0).get_buffer())
        # We can then close our access to the framebuffer
        f.close()
        time.sleep(0.1)
    
    # Now we've got a function that can get the bytes from a pygame surface to the TFT framebuffer, 
    # we can use the usual pygame primitives to draw on our surface before calling the refresh function.
    
    # Here we just blink the screen background in a few colors with the "Hello World!" text
    pygame.font.init()
    defaultFont = pygame.font.SysFont(None,30)
    
    lcd.fill((255,0,0))
    lcd.blit(defaultFont.render("Hello World!", False, (0, 0, 0)),(0, 0))
    refresh()
    
    lcd.fill((0, 255, 0))
    lcd.blit(defaultFont.render("Hello World!", False, (0, 0, 0)),(0, 0))
    refresh()
    
    lcd.fill((0,0,255))
    lcd.blit(defaultFont.render("Hello World!", False, (0, 0, 0)),(0, 0))
    refresh()
    
    lcd.fill((128, 128, 128))
    lcd.blit(defaultFont.render("Hello World!", False, (0, 0, 0)),(0, 0))
    refresh()
    
    ##
    # Everything that follows is for handling the touchscreen touch events via evdev
    ##
    
    # Used to map touch event from the screen hardware to the pygame surface pixels. 
    # (Those values have been found empirically, but I'm working on a simple interactive calibration tool
    tftOrig = (3750, 180)
    tftEnd = (150, 3750)
    tftDelta = (tftEnd [0] - tftOrig [0], tftEnd [1] - tftOrig [1])
    tftAbsDelta = (abs(tftEnd [0] - tftOrig [0]), abs(tftEnd [1] - tftOrig [1]))
    
    # We use evdev to read events from our touchscreen
    # (The device must exist and be properly installed for this to work)
    touch = evdev.InputDevice('/dev/input/touchscreen')
    
    # We make sure the events from the touchscreen will be handled only by this program
    # (so the mouse pointer won't move on X when we touch the TFT screen)
    touch.grab()
    # Prints some info on how evdev sees our input device
    print(touch)
    # Even more info for curious people
    #print(touch.capabilities())
    
    # Here we convert the evdev "hardware" touch coordinates into pygame surface pixel coordinates
    def getPixelsFromCoordinates(coords):
        # TODO check divide by 0!
        if tftDelta [0] < 0:
            x = float(tftAbsDelta [0] - coords [0] + tftEnd [0]) / float(tftAbsDelta [0]) * float(surfaceSize [0])
        else:    
            x = float(coords [0] - tftOrig [0]) / float(tftAbsDelta [0]) * float(surfaceSize [0])
        if tftDelta [1] < 0:
            y = float(tftAbsDelta [1] - coords [1] + tftEnd [1]) / float(tftAbsDelta [1]) * float(surfaceSize [1])
        else:        
            y = float(coords [1] - tftOrig [1]) / float(tftAbsDelta [1]) * float(surfaceSize [1])
        return (int(x), int(y))
    
    # Was useful to see what pieces I would need from the evdev events
    def printEvent(event):
        print(evdev.categorize(event))
        print("Value: {0}".format(event.value))
        print("Type: {0}".format(event.type))
        print("Code: {0}".format(event.code))
    
    # This loop allows us to write red dots on the screen where we touch it 
    while True:
        # TODO get the right ecodes instead of int
        r,w,x = select.select([touch], [], [])
        for event in touch.read():
            if event.type == evdev.ecodes.EV_ABS:
                if event.code == 1:
                    X = event.value
                elif event.code == 0:
                    Y = event.value
            elif event.type == evdev.ecodes.EV_KEY:
                if event.code == 330 and event.value == 1:
                    printEvent(event)
                    p = getPixelsFromCoordinates((X, Y))
                    print("TFT: {0}:{1} | Pixels: {2}:{3}".format(X, Y, p [0], p [1]))
                    pygame.draw.circle(lcd, (255, 0, 0), p , 2, 2)
                    refresh()
    
    exit()
    

    More details

    A quick recap on what I wanted to achieve: my goal is to display content onto a TFT display with the following constraints:

    1. Be able to display another content on the HDMI display without interference (e.g. X on HDMI, the output of a graphical app on the TFT);
    2. be able to use the touch capability of the TFT display for the benefit of the graphical app;
    3. make sure the point above would not interfere with the mouse pointer on the HDMI display;
    4. leverage Python and Pygame to keep it very easy to build whatever graphics/GUI I'd fancy;
    5. keep a less-than-decent-but-sufficient-for-me framerate, e.g. 10 FPS.

    Why not using pygame/SDL1.2.x as instructed in many forums and the adafruit TFT manual?

    First, it doesn't work, at all. I have tried a gazillion versions of libsdl and its dependencies and they all failed consistently. I've tried forcing some libsdl versions downgrades, same with pygame version, just to try to get back to what the software was when my TFT screen was released (~2014). Then I aslo tried switching to C and handle SDL2 primitives directly.

    Furthermore, SDL1.2 is getting old and I believe it is bad practice to build new code on top of old one. That said, I am still using pygame-1.9.4...

    So why not SDL2? Well, they have stopped (or are about to stop) supporting framebuffers. I have not tried their alternative to framebuffers, EGL, as it got more complex the further I digged and it did not look too engaging (so old it felt like necro-browsing). Any fresh help or advice on that would be greatly appreciated BTW.

    What about the touchscreen inputs?

    All the high level solutions that work in a conventional context are embedding a display. I've tried pygame events, pymouse and a couple others that would not work in my case as I got rid of the notion of display on purpose. That's why I had to go back to a generic and low level solution, and the internet introduced my to evdev, see the commented code above for more details.

    Any comment on the above would be greatly appreciated, these are my first step with Raspbian, Python and TFT screens, I reckon I most probably have missed some pretty obvious stuff along the way.