Blog

How to Play Video on a 51 Microcontroller with a 0.96″ OLED (128×64)

Table of Contents

Beginner-friendly, step-by-step guide with Python + C code and SEO-ready structure


Who is this guide for?

If you’re new to embedded systems and want a fun, visual project, this is perfect. We’ll take a standard 8051/51 microcontroller (e.g., STC15F2K60S2) and a LGO12864A-096 (0.96″ 128×64 OLED commonly SSD1306-based), then stream video frames to the screen—smoothly enough to look like a real video.

You’ll learn:

  • How video becomes frames, and frames become 1-bit pixels the OLED understands
  • How to use OpenCV to convert any video into OLED-ready byte data
  • How to send frames over serial (PySerial)
  • How the 51 MCU draws each frame via I²C to the OLED
  • How to reach ≥25 FPS for smooth playback

Demo: What you’ll get

The result is a looping video (e.g., Bad Apple) playing on a tiny 0.96″ OLED. Even with a classic 51 MCU, you can achieve surprisingly fluid motion once you hit ~25 frames per second.


Bill of Materials (BOM)

  • Microcontroller: STC15F2K60S2 (any compatible 8051/51 MCU works)
  • Display: LGO12864A-096, 0.96″ OLED, 128×64, SSD1306-compatible, I²C pins (VCC, GND, SCL, SDA)
  • USB-to-Serial adapter (for flashing and runtime serial streaming)
  • Cables: Dupont wires / breadboard
  • Optional: TF-card module (if you later want offline playback)

⚠️ Voltage notes: Many SSD1306 OLED modules accept 5V on VCC, but their I²C pins are 3.3V logic. Many boards are 5V-tolerant; confirm your module. If not sure, add a small I²C level shifter.


How the whole system works (high-level)

  1. On PC:
    • Split the video into frames with OpenCV
    • Convert each frame (128×64, 1-bit) into 1024 bytes (128 columns × 8 pages) in the format the OLED expects
  2. Send frames via serial:
    • Use PySerial to stream one frame at a time to the MCU (e.g., 345600 baud)
  3. On the MCU (51):
    • Receive exactly 1024 bytes per frame
    • Call the OLED draw routine to push the frame to the display over I²C
  4. Repeat ≥25 times per second → Your eyes perceive continuous motion

Environment setup (PC side)

  • Python 3.9+
  • Install packages: pip install opencv-python pyserial numpy
  • Prepare a short clip (e.g., BadApple.mp4 / .flv). Shorter clips = easier testing.

Step 1 — Convert video frames to OLED bytes (OpenCV)

We’ll: (1) open the video, (2) grayscale + resize to 128×64, (3) threshold to 1-bit, and (4) pack 8 vertical pixels → 1 byte to match SSD1306 “page” addressing (bit0 = top pixel of the 8-pixel block).

# convert_video_to_oled_hex.py
import cv2
import numpy as np

VIDEO_PATH = "BadApple.flv"       # your video
OUT_PATH   = "bad_apple_data.txt" # frame-by-frame byte dump (comma-separated per frame)

WIDTH, HEIGHT = 128, 64           # OLED dimensions

def pack_8_vertical_bits(col_bytes):
    """
    col_bytes: a list/array of 8 pixel values (0 or 1), top -> bottom
    Returns one byte with bit0 = top pixel.
    """
    val = 0
    for i, b in enumerate(col_bytes):
        if b:      # 1 = white (or 'on')
            val |= (1 << i)
    return val

def frame_to_ssd1306_bytes(gray_128x64):
    """
    Convert a 128x64 1-bit frame to 1024 bytes ordered as 8 pages * 128 columns.
    Each page is 8 rows tall. SSD1306 expects 8 vertical pixels per byte (bit0=top).
    """
    # gray_128x64 should be np.uint8 0..255
    # Threshold to 0/1
    mono = (gray_128x64 > 128).astype(np.uint8)

    out = []
    # 8 pages, each page has 8 rows
    for page in range(8):
        row_start = page * 8
        for x in range(WIDTH):
            # take 8 vertical pixels: rows [row_start .. row_start+7], column x
            col_bits = mono[row_start:row_start+8, x]
            byte_val = pack_8_vertical_bits(col_bits.tolist())
            out.append(byte_val)
    return out  # 1024 bytes

def main():
    cap = cv2.VideoCapture(VIDEO_PATH)
    if not cap.isOpened():
        raise RuntimeError("Cannot open video: " + VIDEO_PATH)

    writer = open(OUT_PATH, "w", encoding="utf-8")
    frame_count = 0

    # (Optional) target FPS downsample, e.g., take every 2nd/3rd frame to reduce size
    TAKE_EVERY = 1

    while True:
        ret, frame = cap.read()
        if not ret:
            break
        frame_count += 1
        if frame_count % TAKE_EVERY != 0:
            continue

        # to grayscale, resize
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        gray = cv2.resize(gray, (WIDTH, HEIGHT), interpolation=cv2.INTER_AREA)

        # make bytes
        bytes_1024 = frame_to_ssd1306_bytes(gray)

        # store as a single line per frame, comma-separated ints 0..255 (length=1024)
        writer.write(",".join(map(str, bytes_1024)) + "\n")

    writer.close()
    cap.release()
    print(f"Done. Wrote frames to {OUT_PATH}")

if __name__ == "__main__":
    main()

Output format: Each line = one frame, with 1024 comma-separated integers (0–255). This makes it easy to stream frames line-by-line.

Beginner tip: If your video looks too dark/light, tweak the threshold mono = (gray > 128) to > 100 or > 140 until edges look crisp on OLED.


Step 2 — Stream frames to the MCU over serial (PySerial)

We’ll open a COM port, read the text file line-by-line (frame-by-frame), convert to bytes, and write them out. Start simple; then we’ll show a sturdier version with frame markers.

Simple, easy sender (good for a first run)

# send_frames_simple.py
import serial
import time

COM_PORT = "COM10"     # e.g., "COM10" on Windows, "/dev/ttyUSB0" on Linux, "/dev/tty.usbserial-xxxx" on macOS
BAUD    = 345600
FRAME_DELAY = 0.01      # seconds between frames; tune for your setup

DATA_PATH = "bad_apple_data.txt"

def main():
    ser = serial.Serial(COM_PORT, BAUD, timeout=1)
    if not ser.is_open:
        raise RuntimeError("Failed to open serial port")

    with open(DATA_PATH, "r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line: 
                continue
            vals = [int(x) for x in line.split(",")]
            if len(vals) != 1024:
                # wrong frame size; skip to avoid tearing
                continue
            ser.write(bytes(vals))
            # small delay controls the playback speed; reduce for higher FPS
            time.sleep(FRAME_DELAY)

    ser.close()
    print("All frames sent.")

if __name__ == "__main__":
    main()

Robust sender (recommended for long videos)

Add a frame header and length so the MCU can verify boundaries:

# send_frames_headered.py
import serial
import time

COM_PORT = "COM10"
BAUD     = 345600
FRAME_DELAY = 0.0
DATA_PATH = "bad_apple_data.txt"

START_0 = 0xAA
START_1 = 0x55

def main():
    ser = serial.Serial(COM_PORT, BAUD, timeout=1)
    if not ser.is_open:
        raise RuntimeError("Failed to open serial port")

    with open(DATA_PATH, "r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            vals = [int(x) for x in line.split(",")]
            if len(vals) != 1024:
                continue

            # header: AA 55, len LSB, len MSB (1024 = 0x00 0x04), payload, checksum
            length = len(vals)
            hdr = bytes([START_0, START_1, length & 0xFF, (length >> 8) & 0xFF])
            payload = bytes(vals)
            checksum = (sum(payload) + sum(hdr)) & 0xFF  # simple 8-bit sum

            ser.write(hdr + payload + bytes([checksum]))
            # optional ack handling: ser.read(1)
            time.sleep(FRAME_DELAY)

    ser.close()
    print("All frames sent (headered).")

if __name__ == "__main__":
    main()

Baud rate tips: Start with 115200 if you see corruption, then raise to 230400 / 345600 / 460800 as your wiring and MCU allow.


Step 3 — MCU (51) receives & draws frames on the OLED

Below is a minimal C outline demonstrating: I²C OLED init, set position, draw a full 128×64 bitmap, and a UART receive loop that collects exactly 1024 bytes per frame.

The code is written in generic 8051-style C. For the STC15F2K60S2, adapt pin definitions, I²C functions, and UART setup to your toolchain (Keil, SDCC) and board wiring.

OLED driver snippets (I²C, SSD1306-style)

// oled_ssd1306.h
#ifndef OLED_SSD1306_H
#define OLED_SSD1306_H

#include <stdint.h>

void oled_init(void);
void oled_set_pos(uint8_t x, uint8_t page);
void oled_write_cmd(uint8_t cmd);
void oled_write_data(uint8_t data);
void oled_draw_frame_128x64(const uint8_t *bmp); // 1024 bytes

#endif
// oled_ssd1306.c
#include "oled_ssd1306.h"
// Include your I2C low-level functions: i2c_start(), i2c_write_byte(), i2c_stop(), etc.

#define OLED_ADDR     0x3C  // 0x3C or 0x3D depending on module
#define OLED_CMD_MODE 0x00
#define OLED_DAT_MODE 0x40

static void oled_send_cmd(uint8_t cmd) {
  i2c_start();
  i2c_write_byte((OLED_ADDR << 1) | 0); // write
  i2c_write_byte(OLED_CMD_MODE);
  i2c_write_byte(cmd);
  i2c_stop();
}

static void oled_send_data(uint8_t data) {
  i2c_start();
  i2c_write_byte((OLED_ADDR << 1) | 0);
  i2c_write_byte(OLED_DAT_MODE);
  i2c_write_byte(data);
  i2c_stop();
}

void oled_write_cmd(uint8_t cmd) { oled_send_cmd(cmd); }
void oled_write_data(uint8_t data){ oled_send_data(data); }

void oled_init(void) {
  // init I2C peripheral first
  // Basic SSD1306 init (abridged; add full sequence as per your module)
  oled_write_cmd(0xAE); // display off
  oled_write_cmd(0x20); oled_write_cmd(0x00); // horiz addr mode
  oled_write_cmd(0xB0); // page start
  oled_write_cmd(0xC8); // COM scan dir remapped
  oled_write_cmd(0x00); // low column addr
  oled_write_cmd(0x10); // high column addr
  oled_write_cmd(0x40); // start line addr
  oled_write_cmd(0x81); oled_write_cmd(0x7F); // contrast
  oled_write_cmd(0xA1); // segment remap
  oled_write_cmd(0xA6); // normal display
  oled_write_cmd(0xA8); oled_write_cmd(0x3F); // multiplex 1/64
  oled_write_cmd(0xA4); // resume from RAM
  oled_write_cmd(0xD3); oled_write_cmd(0x00); // display offset
  oled_write_cmd(0xD5); oled_write_cmd(0x80); // clock
  oled_write_cmd(0xD9); oled_write_cmd(0xF1); // precharge
  oled_write_cmd(0xDA); oled_write_cmd(0x12); // com pins
  oled_write_cmd(0xDB); oled_write_cmd(0x40); // vcomh
  oled_write_cmd(0x8D); oled_write_cmd(0x14); // charge pump on
  oled_write_cmd(0xAF); // display on
}

void oled_set_pos(uint8_t x, uint8_t page) {
  oled_write_cmd(0xB0 + page);
  oled_write_cmd(0x00 + (x & 0x0F));
  oled_write_cmd(0x10 + ((x >> 4) & 0x0F));
}

void oled_draw_frame_128x64(const uint8_t *bmp) {
  // bmp is 1024 bytes: page0..page7, each page 128 bytes
  uint16_t j = 0;
  for (uint8_t page = 0; page < 8; page++) {
    oled_set_pos(0, page);
    // Send 128 data bytes for this page
    for (uint8_t x = 0; x < 128; x++) {
      oled_write_data(bmp[j++]);
    }
  }
}

UART receive and main loop (collect 1024 bytes, draw)

// main.c (pseudo-8051 code; adapt UART/I2C to your compiler/MCU)
#include <stdint.h>
#include <string.h>
#include "oled_ssd1306.h"

// UART ring buffer
#define FRAME_SIZE 1024

volatile uint8_t rx_buf[FRAME_SIZE];
volatile uint16_t rx_count = 0;
volatile uint8_t frame_ready = 0;

void uart_init(void) {
  // Configure baud rate (e.g., 345600 if clock allows; otherwise 115200/230400)
  // Enable UART RX interrupt
}

void i2c_init(void) {
  // Configure I2C pins & clock
}

void uart_isr(void) __interrupt(UART_VECTOR) {
  if (RI) {
    RI = 0;
    uint8_t b = SBUF;  // receive register
    if (!frame_ready) {
      rx_buf[rx_count++] = b;
      if (rx_count >= FRAME_SIZE) {
        frame_ready = 1;   // one full frame collected
        rx_count = 0;
      }
    }
  }
}

int main(void) {
  i2c_init();
  uart_init();
  EA = 1;  // global interrupts

  oled_init();

  // Clear screen once (draw all zeros)
  static uint8_t blank[FRAME_SIZE];
  memset(blank, 0, sizeof(blank));
  oled_draw_frame_128x64(blank);

  while (1) {
    if (frame_ready) {
      frame_ready = 0;
      // Draw the received frame
      oled_draw_frame_128x64((const uint8_t*)rx_buf);
      // Immediately start filling next frame in ISR
    }
    // Optional: add small delay or FPS counter
  }
}

If you used “headered” frames on PC:
Parse the start bytes (0xAA, 0x55), length, then read exactly that many payload bytes and verify the checksum before calling oled_draw_frame_128x64().


Achieving smooth playback (≥25 FPS)

  • Baud rate: The higher the better—230400 or 345600 is a good target.
  • Reduce per-frame overhead:
    • Avoid extra copies in ISR; write directly into the frame buffer.
    • Use a double buffer (buffer A receiving, buffer B displaying) to remove tearing.
  • PC timing: Lower or remove the sender’s FRAME_DELAY to push frames faster (avoid overruns).
  • Shorter video or lower FPS: For testing, try 15–20 FPS first, then optimize up.

Optional: Offline playback from TF-card

If you don’t want a PC connected:

  1. Save each 1024-byte frame sequentially on a TF-card (frame0000.bin, frame0001.bin, …).
  2. On the MCU, read 1024 bytes at a time and draw the frame in a loop.
  3. You’ll need an SPI TF-card library and careful buffering to keep FPS high.

Troubleshooting (FAQ)

Q1: The OLED is blank.

  • Check VCC/GND and I²C pins (SCL/SDA).
  • Confirm the I²C address (0x3C vs 0x3D).
  • Ensure the init sequence runs after I²C is configured.

Q2: Frames are garbled or flicker.

  • Start at 115200 baud, then increase.
  • Ensure you receive exactly 1024 bytes per frame (use headered format if needed).
  • Use double buffering and avoid blocking in the ISR.

Q3: The image looks inverted.

  • Flip the threshold logic (> 128 to < 128), or toggle normal/inverse display (0xA6/0xA7).
  • Some modules wire segments/COM differently—try 0xA0/0xA1 or 0xC0/0xC8.

Q4: Can I use a different MCU (STM32, AVR, Arduino)?

  • Yes. Replace the UART & I²C layers and keep the frame format (1024 bytes per frame).

Performance tips and next steps

  • Preprocessing: Edge-enhance or dither frames in Python for better contrast on 1-bit displays.
  • Compression: For offline mode, consider basic RLE; decompress on MCU.
  • Bigger screens: Try 128×64 SPI OLEDs or TFT (but then you’ll need color & more bandwidth).
  • Audio: You can even add a buzzer/PCM playback if your MCU timing allows (advanced).

Source code & downloads

  • Python:
    • convert_video_to_oled_hex.py (OpenCV converter)
    • send_frames_simple.py (basic sender)
    • send_frames_headered.py (robust sender)
  • C (51/8051):
    • oled_ssd1306.[ch] (I²C OLED driver)
    • main.c (UART receive + frame blit)

If you want, I can package these into a GitHub repo structure (/pc/, /mcu/, /docs/) with a README and wiring diagram.


Conclusion

You’ve just seen how a classic 51 microcontroller can play video on a tiny 128×64 OLED by converting frames on a PC and streaming them over serial. The core ideas—1-bit image packing, page addressing, and steady frame timing—are foundational skills you can reuse for many display projects. Start with a short clip, hit a stable 15–25 FPS, then optimize upward.

If you’d like, I can:

  • Turn this into a ready-to-publish Markdown blog post with images and a GitHub link, or
  • Customize the code for your exact STC15 development board and pinout.

Request Free Quote Now!

*We respect your confidentiality and all information is protected.

Hey there, I'm Mr.Zhong!

Sales Engineer

I really enjoy the technology behind LCD display because my work contributes to enhancing the visual experience of various devices. If you have any questions about LCD display, feel free to contact me!

Get Started With Us

*We respect your confidentiality and all information is protected.