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)
- 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
- Send frames via serial:
- Use PySerial to stream one frame at a time to the MCU (e.g., 345600 baud)
- 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
- 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> 100or> 140until 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 callingoled_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_DELAYto 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:
- Save each 1024-byte frame sequentially on a TF-card (
frame0000.bin,frame0001.bin, …). - On the MCU, read 1024 bytes at a time and draw the frame in a loop.
- 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 (
> 128to< 128), or toggle normal/inverse display (0xA6/0xA7). - Some modules wire segments/COM differently—try
0xA0/0xA1or0xC0/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.