CrowPanel Tutorial: Bad Apple

0 - Introduction

In the world of tech memes, there are a few universal truths: if it has a CPU, it can probably run Doom, and if it has a black and white screen, it can play ‘Bad Apple’. Inspired by this, i decided to test my 5.79” CrowPanel by running this iconic monochrome video. Usually an E-Ink display is not suited for video playback, but that is also where the power of editing comes in, and by speeding up the video we get the following result:

If you want to recreate this project by yourself but do not own a CrowPanel, you can get one here at Elecrow’s store. Besides the panel you will also need micro SD Card with about 100 megabytes of free space, I used this one from Sandisk, but any old micro SD card should work. Besides the hardware, you will also need VSCode and PlatformIO.

1 - Setup

The first step is to get the frames from the video, you can do it yourself with Blender or ffmpeg, but you can also go to this archive in the Internet Archive and get a 7zip file with all the frames in png format, which is what I did.

After getting the frames we will rename them, so that every single one has a name like ‘badapple_0000.png’, this will make our lives a little easier when we move to coding on the Esp32.

Place all your frames in the folder ‘data/in’ and run the script below:

import os

# Define the directory where your files are located
directory = 'data/in'

# Loop through all files in the directory
for filename in os.listdir(directory):
    if filename.endswith('.png'):
        # Split the name into prefix and number part
        name, ext = filename.rsplit('_', 1)
        num = ext.split('.')[0]  # Get the number part before '.png'
        
        # Pad the number with leading zeros to make it 4 digits
        new_num = num.zfill(4)  # Ensure the number is 4 digits
        new_filename = f'{name}_{new_num}.png'
        
        # Get the full paths for renaming
        old_file = os.path.join(directory, filename)
        new_file = os.path.join(directory, new_filename)
        
        # Rename the file
        os.rename(old_file, new_file)

        print(f'Renamed: {filename} -> {new_filename}')

Now that we have all the PNG files with the correct name, we need to write a small program to convert the PNGs to monochrome uncompressed bitmaps, we can do that with the help of the ‘pillow’ library, make sure to install it before running the python script below:

# pip install pillow

dir_in = "data/in"
dir_out = "data/out"
filemin = 1
filemax = 6562

import os
from PIL import Image
from shutil import move

print("Hello")

def image_to_bin(path, w, h):
    with Image.open(path) as img: # open image
        img_resized = img.resize((w, h)) # resize
        img_mono = img_resized.convert("1") # convert to black and white
        raw_data = img_mono.tobytes() # convert to binary
        return bytearray(raw_data) # return byte array

# check if out folder exists
if not os.path.exists(dir_out):
    os.makedirs(dir_out)

# Convert png to bin
for filename in os.listdir(dir_in): # foreach
    if filename.endswith(".png"): # png file
        filepath = os.path.join(dir_in, filename)
        binary_data = image_to_bin(filepath, 368, 272) # get binary
        output_path = os.path.join(dir_out, os.path.splitext(filename)[0] + ".bin")
        with open(output_path, "wb") as f: # save to file
            f.write(binary_data)

# Seperate files into folders
for x in range(filemin, filemax + 1):
    path = os.path.join(dir_out, "bad_apple_" + str(x).zfill(4) + ".bin")
    folder = os.path.join(dir_out, "frames", str(x % 100).zfill(4))
    os.makedirs(folder, exist_ok=True) # create folder if needed
    move(path, folder) # move file

print("Bye")

After converting the images to binary, you will also need this file, paste it in a file named ‘platformio.ini’ at the root of your project and re-open VSCode:

[env:esp32-s3-devkitc-1]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
board_build.arduino.memory_type = qio_opi
board_build.flash_mode = qio
board_build.psram_type = opi
board_upload.flash_size = 8MB
board_upload.maximum_size = 8388608
board_build.extra_flags = 
	-DBOARD_HAS_PSRAM
lib_deps = bblanchon/ArduinoJson@^7.2.0

2 - Code

After reloading VSCode to intialize PlatformIO, we can now start to code our program. Create a folder named ‘src’ and, in it, the ‘main.cpp’ file.

For this program we will need to include Arduino, string, SD and the crowpanel headers. You can get the ‘crowpanel_epd.h‘ file here and the ‘crowpanel_pins.h‘ file here.

#include <Arduino.h>
#include <string.h>
#include <SD.h>
#include "crowpanel_epd.h"
#include "crowpanel_pins.h"

Then we define the frame count, which is the total ammount of images to be displayed along with the display buffer, an image buffer, frame index and a bool to handle playing and pausing:

#define FRAME_COUNT 6562

uint8_t drawbuf[27200];
uint8_t imgbuf[12512];
uint32_t framei = 0;
bool playing = false;

The draw function will take the index of the frame and a bool to tell us if we should clear the display before updating it. In this function we make the file path, try to open it, then we try to read it’s contents, if successfull, we clear the display, clear the buffer, draw the image along with the frame number and a ‘paused’ string if currently paused and finally, we send the buffer, update the display and put it to sleep.

void drawFromSD(uint32_t i, bool clear = false)
{
    // make path
    char path[48];
    snprintf(path, 47, "/frames/%04u/bad_apple_%04u.bin", i % 100, i);

    // try open image
    fs::File f = SD.open(path);
    if (!f)
    {
        Serial.println("Can't read image file");
        return;
    }

    // try read image
    size_t readBytes = f.readBytes((char *)imgbuf, 12512);
    if (readBytes != 12512)
    {
        Serial.println("Incorrect image file size");
        return;
    }

    // init and clear display
    EPD_FastMode1Init();
    if (clear)
    {
        EPD_Display_Clear();
        EPD_Update();
    }

    // Clear buffer
    Paint_Clear(WHITE);

    // draw frame, frame index and paused
    EPD_ShowPicture((792 - 368) / 2, 0, 368, 272, imgbuf, BLACK);
    EPD_ShowString(0, 0, (String(i) + "/" + String(FRAME_COUNT)).c_str(), 16, BLACK);
    if (!playing)
        EPD_ShowString(0, 272 - 16 - 1, "paused", 16, BLACK);

    // update display and sleep
    EPD_Display(drawbuf);
    EPD_FastUpdate();
    EPD_DeepSleep();
}

On setup, turn on the power led, initialize the serial port, set the button pins to input, initialize the sd card, initialize the display and draw buffer and finally, draw frame 240 (you can change the number):

void setup()
{
    IO_LED_INIT;
    IO_LED_ON;
    Serial.begin(115200);

    // Set buttons as input
    pinMode(IO_RSW_UP, INPUT);
    pinMode(IO_RSW_DOWN, INPUT);
    pinMode(IO_SW_MENU, INPUT);
    pinMode(IO_SW_EXIT, INPUT);

    // Turn on and begin SD
    pinMode(IO_SD_POWER, OUTPUT);
    digitalWrite(IO_SD_POWER, HIGH);
    vTaskDelay(100 / portTICK_PERIOD_MS);

    SPI.begin(IO_SD_CLK, IO_SD_MISO, IO_SD_MOSI, IO_SD_CS);
    if (!SD.begin(IO_SD_CS))
    {
        Serial.println("SD: Can't begin SD");
        while (1);
    }
    
    // Turn on and clear display
    pinMode(IO_EPD_POWER, OUTPUT);
    digitalWrite(IO_EPD_POWER, HIGH);
    EPD_GPIOInit();
    EPD_HW_RESET();

    // Init draw buffer
    Paint_NewImage(drawbuf, EPD_W, EPD_H, Rotation, WHITE);
    Paint_Clear(WHITE);

    drawFromSD(240, true);
}

On loop, if the menu button is pressed, we toggle the play status, if we are playing, draw the next frame, then for the up and down button, go up or down a frame and as for the exit button, I made it restart the playback:

void loop()
{
    if (!digitalRead(IO_SW_MENU))
    {
        playing = !playing;
        drawFromSD(framei, false);
    }

    if (playing)
    {
        framei++;
        if (framei >= FRAME_COUNT)
            playing = false;
        drawFromSD(++framei, false);
    }
    
    if (!digitalRead(IO_RSW_DOWN))
    {
        if (framei > 1)
            framei -= 1;
        else 
            framei = FRAME_COUNT;
        drawFromSD(framei, false);
    }

    if (!digitalRead(IO_RSW_UP))
    {
        if (framei < FRAME_COUNT)
            framei += 1;
        else 
            framei = 1;
        drawFromSD(framei, false);
    }

    if (!digitalRead(IO_SW_EXIT))
    {
        framei = 1;
        playing = false;
        drawFromSD(framei, false);
    }
}

And that’s it, now copy your binary frames to a SD card, plug it into the CrowPanel and upload your code!

Thanks for reading and stay tuned for more tech insights and tutorials. Until next time, and keep exploring the world of tech!