Ryan Lindell

Electrical Engineering · UH Mānoa

Bad Apple Box

An ESP32 + OLED build that plays the Bad Apple!! video from Touhou.

Overview

This project is a fun embedded challenge: take a well-known high-contrast video (“Bad Apple!!”) and get it to play on a 128×32 OLED display driven by an ESP32.

The whole thing is really a data problem wearing a video costume. All 6,527 frames have to live in flash memory on the microcontroller itself — there's no SD card, no streaming, no network. Getting 3.19 MiB of raw framebuffers down to something that fits, and then unpacking it fast enough to look like motion, is the actual project.

Tech

  • Microcontroller: ESP32 (4 MB flash) — upgraded from an Arduino Nano
  • Display: SSD1306 OLED, 128×32, driven over I²C at 400 kHz
  • Toolchain: ffmpeg for frame extraction, Python + Pillow for encoding, C++ for playback
  • Focus: run-length compression and fitting a video into flash

By the numbers

  • Frames: 6,527
  • Raw framebuffers: 3,341,824 bytes (~3.19 MiB) — 512 bytes per frame
  • After RLE: 771,686 bytes (~754 KiB)
  • Compression: 4.33×, about a 77% reduction
  • Arduino Nano flash: 32 KB — roughly 100× too small, even compressed

Write-up

This has been on my mind for a while, since seeing all those crazy videos of people putting Bad Apple on whatever you can think of — especially that one where someone uses Desmos. I have no idea how that's possible. I'm really glad I got to do this project.

The first step was getting the OLED display and all the parts. Luckily for me, UH Mānoa did an event called Bytes & Bites, where we got food and free parts to build an OLED time display: the OLED screen I would end up using for Bad Apple, a mini Arduino, and some other non-essential components like a breadboard and cables.

A laser-cut plywood box with finger joints, an OLED display in the front panel showing the time 13:16:23
The kit built the way it was meant to be built — a laser-cut box that tells you the time. This is the box the project is named after.

Getting the video into frames

With the hardware sorted, the next thing was getting the Bad Apple video itself — finding it on YouTube, downloading it, then turning it into individual frames. I did this using ffmpeg, which was the easy part — it scales every frame down to the OLED's 128×32 resolution and dumps them out as PNGs.

I don't remember the exact invocation anymore, but it was something along these lines — set a frame rate, scale down to the panel size, and force everything to pure black and white on the way out:

ffmpeg -i bad_apple.mp4 \
  -vf "fps=30,scale=128:32,format=gray" \
  -pix_fmt monob \
  frames/%05d.png
Roughly the frame-extraction step, reconstructed from memory.

The hard step was turning those frames into code the OLED could actually display. That was done with a Python program.

Every frame is 128×32, which is 4,096 pixels. Pillow's convert('1') collapses each one down to a single bit — pure black or pure white, no grays in between — and getdata() hands back a flat list of all 4,096 values in reading order.

def compress_frame_rle(image_path):
    img = Image.open(image_path).convert('1') # Convert to 1-bit
    pixels = list(img.getdata()) # Get flat list of 4096 pixels (128x32)
From frames.py — one frame in, a flat pixel list out.

The memory problem

The result of this was bad_apple_rle.h, a 4.46 MB file — though most of that bulk is just text formatting, since every byte is written out as 0x1f,  in the source. The actual data is closer to 754 KiB. But there's a problem I noticed pretty quickly: the Arduino Nano only has 32 KB of flash memory. The video wasn't close to fitting — it wasn't even in the same order of magnitude. So I needed to upgrade to an ESP32, which has 4 MB.

An ESP32 development board wired by jumper leads into the opened plywood box, on a cluttered workbench
Swapping the Nano out for an ESP32 — same box, same OLED, a hundred times the storage.

And even with the upgrade, I still needed a compression algorithm. Each 128×32 frame is 4,096 pixels, which packs into 512 bytes. Times 6,527 frames, that's 3.19 MiB of raw framebuffers — technically under 4 MB, but with nothing left over for the program itself, and no margin at all. Compression wasn't optional.

The trick is that Bad Apple is almost entirely large flat shapes. A row of 128 identical pixels doesn't need 128 entries — it needs one entry that says “white, 128 times.” That's run-length encoding, and it's a near-perfect match for this video.

Each run gets packed into a single byte: the top bit stores the color, and the bottom seven store how many pixels long the run is. Seven bits caps a run at 127, which is why the loop cuts a run off once it hits that ceiling.

for pixel in pixels:
    # If color matches and count < 127, keep counting
    if pixel == current_color and count < 127:
        count += 1
    else:
        # Pack color into the highest bit (bit 7), count into bits 0-6
        color_bit = 0x80 if current_color > 0 else 0x00
        rle_data.append(color_bit | count)

        current_color = pixel
        count = 1
The encoder loop in frames.py.

Averaged across all 6,527 frames that works out to about 118 bytes per frame, down from 512 — a 4.33× reduction, and the difference between “this fits comfortably” and “this doesn't fit at all.”

The other half of the file is a lookup table. Since every frame compresses to a different length, the ESP32 has no way to know where frame 4,000 begins unless you write it down, so the encoder tracks a running byte offset as it goes:

# Record where this frame starts so the ESP32 can find it
frame_offsets.append(total_bytes)
total_bytes += len(frame_data)
Building the frame index — 6,527 offsets, four bytes each.

Playing it back

On the ESP32 side, decoding is just the encoder run backwards. Look up where the frame starts and where the next one starts — the difference is how many bytes to read — then walk through them, unpacking each byte back into a color and a run length.

uint8_t packet = pgm_read_byte(&(bad_apple_rle[offset + i]));

uint8_t color = (packet & 0x80) ? SSD1306_WHITE : SSD1306_BLACK;
uint8_t count = (packet & 0x7F);

for (uint8_t j = 0; j < count; j++) {
    int x = pixelIndex % 128;
    int y = pixelIndex / 128;
    display.drawPixel(x, y, color);
    pixelIndex++;
}
The decoder inside drawRLEFrame().

0x80 masks off the color bit and 0x7F masks off the count. And since the pixels were stored in reading order, turning a flat index back into screen coordinates is just divide and remainder: % 128 gives the column, / 128 gives the row.

The result

The final result of this, after a lot of tweaking and fixing, was a functional Bad Apple OLED display!!

The closed plywood box with the OLED showing a small white silhouette from the Bad Apple video
A single silhouette mid-playback.
The same box a few frames later, the OLED showing two silhouetted figures side by side
A few frames later — two figures, still readable at 128×32.

The nice part is that from the outside it's the exact same box from the workshop. Same plywood, same little screen — it just isn't telling the time anymore.

Once again this was a fun little project that had most of the stuff already done for me, and developing it a bit further into something I wanted to do for a long time was a lot of fun. I hope you like it as much as I do.

Links

Code on GitHub