ESP32-Cam Cheap Heads Up Display or C.H.U.D.
This post is a continuation from an earlier post that introduced the concept of writing pixel data to the ESP32-Cam module frame buffer. The buffer can be streamed or written as an image file to the microSD media. In this post I’ll expand on text and graphics with a HUD styled overlay. I’ll be sticking with saving images sequences and not covering streaming here.
The code in this post was generated and refined using OpenAI’s ChatGPT. I won’t provide the semantics of the prompts here, but will cover some key items that were discovered during the development of the code. Here is a demo video of the HUD with pitch, roll, and yaw values derived from a time variable.
Getting to this point took several steps and extensive debugging. AI’s interpretation of my prompts was a game of back and forth. There were two essential issues that I discovered which required more work than I anticipated. The first was including a simple degree symbol in the text string. The code generated by AI was stuck on the UTF-8 encoding value being 176. After some brute testing with all ASCII codes, I found that 123 was the correct value. Here are my notes on that phase of the development.
AI replied: "°" is a UTF-8 encoded degree symbol in the source string. {0x06,0x09,0x09,0x06,0x00}, // ° (176) I prompted: These are the mappings for ASCII number value to actual character ... ° = 123 AI replied: This confirms the real issue: The degree symbol (°) is mapped to ASCII code 123 in your setup — not 176.
There was more back and forth than this, but I just want to point out that it was odd to have a response so fundamentally flawed. I was left to suspect it was similar to speed governor you would find on a rental truck so you can’t drive 120 mph on the highway.
The next strange item was the color space debugging. In the code provided, writing to the buffer requires working in RGB565 color space. The frames are then saved to the jpg format. I had issues with this in my earlier post with the images having a psychedelic look because YUV channels were directly set to RGB channels. The color space needs to be correctly converted, not just interchanged. That was all well and good, but AI was responding with a macro that had the blue and red channels reversed.
AI replied: You are using RGB565 format, a common color format for displays with 16 bits per pixel 5 bits red (0..31) 6 bits green (0..63) 5 bits blue (0..31) Helper macro — easy way to convert 8-bit RGB → RGB565: If you want to define colors using normal 0..255 values, use this macro: #define RGB565(r8, g8, b8) ( ((r8 & 0xF8) << 8) | ((g8 & 0xFC) << 3) | (b8 >> 3) ) I prompted: But the colors are actually blue, green, red uint16_t blue = RGB565(255, 0, 0); // Blue uint16_t green = RGB565(0, 255, 0); // Green uint16_t red = RGB565(0, 0, 255); // Red
I had to stop from the HUD project and create a new project to debug the character and color issue. Here is a video of the final debug that sorted out the character and color issue.
The frame rate was another issue. I suspected that IDE or some library was introducing a bottleneck. AI confirmed this by pointing out regressions from newer libraries that were intended to provide compatiblity. Here is AI’s response to my prompt.
I prompted: This leads me to believe that the board library or the IDE is introducing the bottleneck... Provide me sources of the core regressions mentioned, specific to many JPEG/SDMMC slowdowns reported. ChatGPT said: AI Replied: Here are some specific sources and background regarding the ESP32 Arduino Core regressions that have impacted JPEG and SD/MMC performance in recent versions. This is an issue I track closely, because many users run into this exact problem when doing image/video work with ESP32-CAM and SD. https://github.com/espressif/esp32-camera/issues/537 "Low fps on ESP32-CAM after upgrading to newer core". → Users reporting fps drops after upgrading from 1.0.x → 2.0.x. https://github.com/espressif/arduino-esp32/issues/6609 "SD_MMC performance regression" → Reports of SDMMC writes becoming 2-5× slower after Core 2.0.5+. https://github.com/espressif/arduino-esp32/issues/7221 "SDMMC issues with camera" → Many users confirm 1.0.6 works great, but 2.x series is slower for SDMMC + JPEG. https://github.com/espressif/arduino-esp32/issues/7255 "JPEG encoder slow performance on newer core" → JPEG encode latency increased in 2.x series. The main reasons seen in these reports: Camera driver moved to I2S DMA double buffering → which adds some latency when used with SDMMC. SDMMC driver changed default clock to 10 MHz instead of 20-40 MHz for "compatibility" → silent regression. Heap allocation pattern changed → increased fragmentation, leading to malloc stalls. JPEG encoder changes in esp32-camera fork used by 2.x sometimes switch to software path when heap is low → big fps hit.
So, I made some changes, but ultimately settled with what I had and decided to save this effort for another time. With that all out of the way, I concluded with this code for the C.H.U.D.
/* ESP32-CAM_Video_ver14c This program captures data only to the video frame The code was developed using ChatGPT Edited by Patrick Gilfeather June 28 2025 Software used - Arduino IDE version 1.8.19 - ESP32 core version 2.0.4 - Board AI Thinker ESP32-CAM - Partition Scheme Huge APP (3MB No OTA) Hardware used - Espressif ESP32-Cam module - SanDisk Ultra 64GB microSD class 10 format FAT (32-bit version) */ #include "FS.h" #include "SD_MMC.h" #include "esp_camera.h" #include "img_converters.h" #define FRAME_WIDTH 320 #define FRAME_HEIGHT 240 #define FRAME_INTERVAL_MS 250 // Frame delay uint32_t frameCounter = 0; unsigned long lastFrameTime = 0; unsigned long cycleDuration = 60000; // 60 seconds in milliseconds #define RGB565(r8, g8, b8) ( ((r8 & 0xF8) << 8) | ((g8 & 0xFC) << 3) | (b8 >> 3) ) // The format follows this (blue, green, red) uint16_t black = RGB565(0, 0, 0); // Black uint16_t white = RGB565(255, 255, 255); // White uint16_t blue = RGB565(255, 0, 0); // Blue uint16_t green = RGB565(0, 255, 0); // Green uint16_t red = RGB565(0, 0, 255); // Red uint16_t yellow = RGB565(0, 255, 255); // Yellow uint16_t cyan = RGB565(255, 255, 0); // Cyan uint16_t magenta = RGB565(255, 0, 255); // Magenta uint16_t orange = RGB565(0, 128, 255); // Orange uint16_t brown = RGB565(32, 32, 128); // Brown uint16_t gray = RGB565(128, 128, 128); // Gray float pitch = 0; float roll = 0; float yaw = 0; int len = 0; static const uint8_t font5x7[][5] = { // ASCII 32 to 123 (printable characters) {0x00,0x00,0x00,0x00,0x00}, // = 32 {0x00,0x00,0x5F,0x00,0x00}, // ! = 33 {0x00,0x07,0x00,0x07,0x00}, // " = 34 {0x14,0x7F,0x14,0x7F,0x14}, // # = 35 {0x24,0x2A,0x7F,0x2A,0x12}, // $ = 36 {0x23,0x13,0x08,0x64,0x62}, // % = 37 {0x36,0x49,0x55,0x22,0x50}, // & = 38 {0x00,0x05,0x03,0x00,0x00}, // ' = 39 {0x00,0x1C,0x22,0x41,0x00}, // ( = 40 {0x00,0x41,0x22,0x1C,0x00}, // ) = 41 {0x14,0x08,0x3E,0x08,0x14}, // * = 42 {0x08,0x08,0x3E,0x08,0x08}, // + = 43 {0x00,0x50,0x30,0x00,0x00}, // , = 44 {0x08,0x08,0x08,0x08,0x08}, // - = 45 {0x00,0x60,0x60,0x00,0x00}, // . = 46 {0x20,0x10,0x08,0x04,0x02}, // / = 47 {0x3E,0x51,0x49,0x45,0x3E}, // 0 = 48 {0x00,0x42,0x7F,0x40,0x00}, // 1 = 49 {0x42,0x61,0x51,0x49,0x46}, // 2 = 50 {0x21,0x41,0x45,0x4B,0x31}, // 3 = 51 {0x18,0x14,0x12,0x7F,0x10}, // 4 = 52 {0x27,0x45,0x45,0x45,0x39}, // 5 = 53 {0x3C,0x4A,0x49,0x49,0x30}, // 6 = 54 {0x01,0x71,0x09,0x05,0x03}, // 7 = 55 {0x36,0x49,0x49,0x49,0x36}, // 8 = 56 {0x06,0x49,0x49,0x29,0x1E}, // 9 = 57 {0x00,0x36,0x36,0x00,0x00}, // : = 58 {0x00,0x56,0x36,0x00,0x00}, // ; = 59 {0x08,0x14,0x22,0x41,0x00}, // < = 60 {0x14,0x14,0x14,0x14,0x14}, // = = 61 {0x00,0x41,0x22,0x14,0x08}, // > = 62 {0x02,0x01,0x51,0x09,0x06}, // ? = 63 {0x32,0x49,0x79,0x41,0x3E}, // @ = 64 {0x7E,0x11,0x11,0x11,0x7E}, // A = 65 {0x7F,0x49,0x49,0x49,0x36}, // B = 66 {0x3E,0x41,0x41,0x41,0x22}, // C = 67 {0x7F,0x41,0x41,0x22,0x1C}, // D = 68 {0x7F,0x49,0x49,0x49,0x41}, // E = 69 {0x7F,0x09,0x09,0x09,0x01}, // F = 70 {0x3E,0x41,0x49,0x49,0x7A}, // G = 71 {0x7F,0x08,0x08,0x08,0x7F}, // H = 72 {0x00,0x41,0x7F,0x41,0x00}, // I = 73 {0x20,0x40,0x41,0x3F,0x01}, // J = 74 {0x7F,0x08,0x14,0x22,0x41}, // K = 75 {0x7F,0x40,0x40,0x40,0x40}, // L = 76 {0x7F,0x02,0x0C,0x02,0x7F}, // M = 77 {0x7F,0x04,0x08,0x10,0x7F}, // N = 78 {0x3E,0x41,0x41,0x41,0x3E}, // O = 79 {0x7F,0x09,0x09,0x09,0x06}, // P = 80 {0x3E,0x41,0x51,0x21,0x5E}, // Q = 81 {0x7F,0x09,0x19,0x29,0x46}, // R = 82 {0x46,0x49,0x49,0x49,0x31}, // S = 83 {0x01,0x01,0x7F,0x01,0x01}, // T = 84 {0x3F,0x40,0x40,0x40,0x3F}, // U = 85 {0x1F,0x20,0x40,0x20,0x1F}, // V = 86 {0x3F,0x40,0x38,0x40,0x3F}, // W = 87 {0x63,0x14,0x08,0x14,0x63}, // X = 88 {0x07,0x08,0x70,0x08,0x07}, // Y = 89 {0x61,0x51,0x49,0x45,0x43}, // Z = 90 {0x00,0x7F,0x41,0x41,0x00}, // [ = 91 {0x02,0x04,0x08,0x10,0x20}, // \ = 92 {0x00,0x41,0x41,0x7F,0x00}, // ] = 93 {0x04,0x02,0x01,0x02,0x04}, // ^ = 94 {0x40,0x40,0x40,0x40,0x40}, // _ = 95 {0x00,0x01,0x02,0x04,0x00}, // ` = 96 {0x20,0x54,0x54,0x54,0x78}, // a = 97 {0x7F,0x48,0x44,0x44,0x38}, // b = 98 {0x38,0x44,0x44,0x44,0x20}, // c = 99 {0x38,0x44,0x44,0x48,0x7F}, // d = 100 {0x38,0x54,0x54,0x54,0x18}, // e = 101 {0x08,0x7E,0x09,0x01,0x02}, // f = 102 {0x0C,0x52,0x52,0x52,0x3E}, // g = 103 {0x7F,0x08,0x04,0x04,0x78}, // h = 104 {0x00,0x44,0x7D,0x40,0x00}, // i = 105 {0x20,0x40,0x44,0x3D,0x00}, // j = 106 {0x7F,0x10,0x28,0x44,0x00}, // k = 107 {0x00,0x41,0x7F,0x40,0x00}, // l = 108 {0x7C,0x04,0x18,0x04,0x78}, // m = 109 {0x7C,0x08,0x04,0x04,0x78}, // n = 110 {0x38,0x44,0x44,0x44,0x38}, // o = 111 {0x7C,0x14,0x14,0x14,0x08}, // p = 112 {0x08,0x14,0x14,0x18,0x7C}, // q = 113 {0x7C,0x08,0x04,0x04,0x08}, // r = 114 {0x48,0x54,0x54,0x54,0x20}, // s = 115 {0x04,0x3F,0x44,0x40,0x20}, // t = 116 {0x3C,0x40,0x40,0x20,0x7C}, // u = 117 {0x1C,0x20,0x40,0x20,0x1C}, // v = 118 {0x3C,0x40,0x30,0x40,0x3C}, // w = 119 {0x44,0x28,0x10,0x28,0x44}, // x = 120 {0x0C,0x50,0x50,0x50,0x3C}, // y = 121 {0x44,0x64,0x54,0x4C,0x44}, // z = 122 {0x06,0x09,0x09,0x06,0x00} // ° = 123 }; // ------------------- SETUP ------------------- void setup() { delay(10); if (!SD_MMC.begin()) { return; } } // ------------------- LOOP ------------------- void loop() { uint16_t* buffer = createBlackFrameRGB565(); if (!buffer) return; /* float pitch = 0; // Example pitch simulation float roll = 0; // Example roll simulation */ pitch = sin(millis() * 0.00001) * 15; // Example pitch simulation roll = sin(millis() * 0.00001) * 25; // Example roll simulation yaw = sin(millis() * 0.00001) * 180; // Example yaw simulation drawPitchLadder(buffer, FRAME_WIDTH, FRAME_HEIGHT, pitch, roll); drawBearingLine(buffer, FRAME_WIDTH, FRAME_HEIGHT, yaw, green); drawUpperRightText(buffer, FRAME_WIDTH, FRAME_HEIGHT); drawUpperLeftText(buffer, FRAME_WIDTH, FRAME_HEIGHT); drawMidLeftText(buffer, FRAME_WIDTH, FRAME_HEIGHT); drawMidRightText(buffer, FRAME_WIDTH, FRAME_HEIGHT); drawLowerLeftText(buffer, FRAME_WIDTH, FRAME_HEIGHT); drawLowerRightText(buffer, FRAME_WIDTH, FRAME_HEIGHT); char filename[32]; sprintf(filename, "/frame_sim_%05lu.jpg", frameCounter++); saveAsJPEG(buffer, FRAME_WIDTH, FRAME_HEIGHT, filename); free(buffer); } // ------------------- FRAME CREATION ------------------- uint16_t* createBlackFrameRGB565() { size_t bufferSize = FRAME_WIDTH * FRAME_HEIGHT * sizeof(uint16_t); uint16_t* buffer = (uint16_t*)ps_malloc(bufferSize); if (!buffer) { return nullptr; } memset(buffer, 0x00, bufferSize); return buffer; } // ------------------- OVERLAYS ------------------- void drawPitchLadder(uint16_t* buffer, int w, int h, float pitchDegrees, float rollDegrees) { int cx = w / 2; int cy = h / 2; // Convert angles to radians and pixels float rollRad = rollDegrees * DEG_TO_RAD; float pitchPixelsPerDegree = h / 90.0; // scale for pitch effect float cosRoll = cos(rollRad); float sinRoll = sin(rollRad); // Calculate total vertical pitch offset int pitchOffset = (int)(pitchDegrees * pitchPixelsPerDegree); int stepDegrees = 10; int maxDegrees = 180; // extend range beyond the display to cover possible shifts int lineLength = w / 16; for (int ladderDegree = -maxDegrees; ladderDegree <= maxDegrees; ladderDegree += stepDegrees) { if (ladderDegree == 0) continue; // skip horizon line // Calculate ladder line vertical position including pitch offset int yOffset = (int)(-ladderDegree * pitchPixelsPerDegree) + pitchOffset; int y = cy + yOffset; // Skip lines outside the visible frame if (y < 0 || y >= h) continue; int x0 = cx - lineLength / 2; int y0 = y; int x1 = cx + lineLength / 2; int y1 = y; rotatePoint(x0, y0, cx, cy, cosRoll, sinRoll, &x0, &y0); rotatePoint(x1, y1, cx, cy, cosRoll, sinRoll, &x1, &y1); uint16_t color = (ladderDegree > 0) ? cyan : orange; drawLineRGB565(buffer, w, h, x0, y0, x1, y1, color); // Draw label char label[8]; sprintf(label, "%+d", ladderDegree); int labelX = cx + (lineLength / 2) + 5; int labelY = y; rotatePoint(labelX, labelY, cx, cy, cosRoll, sinRoll, &labelX, &labelY); drawText(buffer, w, h, labelX, labelY, label, color); } // Draw horizon (center) line with pitch offset int horizonLength = w / 8; int horizonY = cy + pitchOffset; // horizon shifts with pitch // int horizonY = cy - pitchOffset; // horizon shifts with pitch int x0 = cx - horizonLength / 2; int y0 = horizonY; int x1 = cx + horizonLength / 2; int y1 = horizonY; rotatePoint(x0, y0, cx, cy, cosRoll, sinRoll, &x0, &y0); rotatePoint(x1, y1, cx, cy, cosRoll, sinRoll, &x1, &y1); drawLineRGB565(buffer, w, h, x0, y0, x1, y1, orange); // Draw a black masking to cover ladder overlap on the buffer int rectWidth = w; int rectHeight = h - 200; // Start the rectangle's top int startX = 0; int startY = 0; // Draw the rectangle for (int y = 0; y < rectHeight; y++) { for (int x = 0; x < rectWidth; x++) { int px = startX + x; int py = startY + y; if (px >= 0 && px < w && py >= 0 && py < h) { buffer[py * w + px] = black; } } } // Start the rectangle's bottom startX = 0; startY = 188; // Draw the rectangle for (int y = 0; y < rectHeight; y++) { for (int x = 0; x < rectWidth; x++) { int px = startX + x; int py = startY + y; if (px >= 0 && px < w && py >= 0 && py < h) { buffer[py * w + px] = black; } } } // Start the rectangle's bottom startX = 0; startY = 200; // Draw the rectangle for (int y = 0; y < rectHeight; y++) { for (int x = 0; x < rectWidth; x++) { int px = startX + x; int py = startY + y; if (px >= 0 && px < w && py >= 0 && py < h) { buffer[py * w + px] = black; } } } // Aircraft symbol (rolls only) int wingSpan = w / 16; int noseLength = h / 20; int ax0 = cx - wingSpan; int ay0 = cy - 12; // Shift shape 12 pixels higher int ax1 = cx; int ay1 = ay0 + noseLength; int ax2 = cx + wingSpan; int ay2 = ay0; rotatePoint(ax0, ay0, cx, cy, cosRoll, sinRoll, &ax0, &ay0); rotatePoint(ax1, ay1, cx, cy, cosRoll, sinRoll, &ax1, &ay1); rotatePoint(ax2, ay2, cx, cy, cosRoll, sinRoll, &ax2, &ay2); // Draw aircraft V-shape drawLineRGB565(buffer, w, h, ax0, ay0, ax1, ay1, red); drawLineRGB565(buffer, w, h, ax1, ay1, ax2, ay2, green); // Draw circular dot at center int dotRadius = 3; // Radius of the circle (6-pixel diameter) for (int dx = -dotRadius; dx <= dotRadius; dx++) { for (int dy = -dotRadius; dy <= dotRadius; dy++) { if (dx*dx + dy*dy <= dotRadius*dotRadius) { // Circle equation int px = cx + dx; int py = cy + dy; if (px >= 0 && px < w && py >= 0 && py < h) { buffer[py * w + px] = green; // Or use a contrasting color } } } } // Draw circular dot at center dotRadius = 2; // Radius of the circle (4-pixel diameter) for (int dx = -dotRadius; dx <= dotRadius; dx++) { for (int dy = -dotRadius; dy <= dotRadius; dy++) { if (dx*dx + dy*dy <= dotRadius*dotRadius) { // Circle equation int px = cx + dx; int py = cy + dy; if (px >= 0 && px < w && py >= 0 && py < h) { buffer[py * w + px] = black; // Or use a contrasting color } } } } } void rotatePoint(int x, int y, int cx, int cy, float cosA, float sinA, int* outX, int* outY) { int dx = x - cx; int dy = y - cy; *outX = cx + (int)(dx * cosA - dy * sinA); *outY = cy + (int)(dx * sinA + dy * cosA); } void drawBearingLine(uint16_t* buffer, int w, int h, float yawDegrees, uint16_t color) { int lineY = h - 32; // Fixed height, 35 pixels from bottom int centerX = w / 2; int lineLength = w * 0.65; // Total compass line length int halfLine = lineLength / 2; // Draw horizontal base line int x0 = centerX - halfLine; int x1 = centerX + halfLine; drawLineRGB565(buffer, w, h, x0, lineY, x1, lineY, color); // Draw moving tick marks and labels int degreesPerTick = 10; int totalDegrees = 360; // Full circle int tickHeight = 5; int majorTickHeight = 10; int labelOffset = 12; float scaleOffsetDegrees = -yawDegrees; // Move scale with yaw for (int deg = 0; deg < totalDegrees; deg += degreesPerTick) { // Adjust degree by scale offset and wrap around float displayDeg = deg + scaleOffsetDegrees; while (displayDeg < 0) displayDeg += 360; while (displayDeg >= 360) displayDeg -= 360; // Map degrees to X position on tape float degFromCenter = displayDeg > 180 ? displayDeg - 360 : displayDeg; float fraction = degFromCenter / 90.0f; // +/- 90 degrees if (fraction < -1.0f || fraction > 1.0f) continue; int tickX = centerX + (int)(fraction * halfLine); int thisTickHeight = (deg % 30 == 0) ? majorTickHeight : tickHeight; drawLineRGB565(buffer, w, h, tickX, lineY - thisTickHeight / 2, tickX, lineY + thisTickHeight / 2, color); if (deg % 30 == 0) { // Compass labels for cardinal points const char* label = nullptr; switch (deg) { case 0: label = "N"; break; case 90: label = "E"; break; case 180: label = "S"; break; case 270: label = "W"; break; default: { static char labelBuf[8]; sprintf(labelBuf, "%d", deg); label = labelBuf; break; } } int len = strlen(label); int textX = tickX - (len * 6) / 2; int textY = lineY - thisTickHeight / 2 - labelOffset; drawText(buffer, w, h, textX, textY, label, color); } } // Draw center triangle indicator int triHeight = 10; int triBase = 8; int topX = centerX; int topY = lineY - triHeight; int leftX = centerX - triBase / 2; int leftY = lineY; int rightX = centerX + triBase / 2; int rightY = lineY; // Draw triangle edges drawLineRGB565(buffer, w, h, leftX, leftY, topX, topY, color); drawLineRGB565(buffer, w, h, topX, topY, rightX, rightY, color); drawLineRGB565(buffer, w, h, rightX, rightY, leftX, leftY, color); } void drawLineRGB565(uint16_t* pixels, int w, int h, int x0, int y0, int x1, int y1, uint16_t color) { int dx = abs(x1 - x0), sx = x0 < x1 ? 1 : -1; int dy = -abs(y1 - y0), sy = y0 < y1 ? 1 : -1; int err = dx + dy, e2; while (true) { if (x0 >= 0 && x0 < w && y0 >= 0 && y0 < h) pixels[y0 * w + x0] = color; if (x0 == x1 && y0 == y1) break; e2 = 2 * err; if (e2 >= dy) { err += dy; x0 += sx; } if (e2 <= dx) { err += dx; y0 += sy; } } } // ------------------- TEXT ------------------- void drawChar(uint16_t* buf, int w, int h, int x, int y, char c, uint16_t color) { const uint8_t* chr = nullptr; if (c >= 32 && c <= 123) chr = font5x7[c - 32]; else return; for (int col = 0; col < 5; col++) { uint8_t line = chr[col]; for (int row = 0; row < 7; row++) { if (line & (1 << row)) { int px = x + col; int py = y + row; if (px >= 0 && px < w && py >= 0 && py < h) buf[py * w + px] = color; } } } } void drawText(uint16_t* buf, int w, int h, int x, int y, const char* text, uint16_t color) { while (*text) { drawChar(buf, w, h, x, y, *text++, color); x += 6; } } void drawUpperRightText(uint16_t* buf, int w, int h) { char UpperRightText[32]; char pitchStr[16]; dtostrf(pitch, 6, 2, pitchStr); // Width: 6, Precision: 2 (e.g., " 12.34") sprintf(UpperRightText, "Pitch: %s%c", pitchStr, 123); // 123 is the index for ° symbol int len = strlen(UpperRightText); drawText(buf, w, h, w - len * 6 - 5, 5, UpperRightText, green); // Green char rollStr[16]; dtostrf(roll, 6, 2, rollStr); // Width: 6, Precision: 2 (e.g., " 12.34") sprintf(UpperRightText, "Roll: %s%c", rollStr, 123); // 123 is the index for ° symbol len = strlen(UpperRightText); drawText(buf, w, h, w - len * 6 - 5, 14, UpperRightText, green); // Green char yawStr[16]; // Normalize yaw to 0–360 range float displayYaw = yaw; if (displayYaw < 0) displayYaw += 360; dtostrf(displayYaw, 6, 2, yawStr); // Width: 6, Precision: 2 sprintf(UpperRightText, "Yaw: %s%c", yawStr, 123); // 123 is the index for ° symbol len = strlen(UpperRightText); drawText(buf, w, h, w - len * 6 - 5, 23, UpperRightText, green); // Green sprintf(UpperRightText, "Alt: "); len = strlen(UpperRightText); drawText(buf, w, h, w - len * 6 - 5, 32, UpperRightText, green); // Green } void drawUpperLeftText(uint16_t* buf, int w, int h) { char UpperLeftText[32]; sprintf(UpperLeftText, "GPS: "); drawText(buf, w, h, 5, 5, UpperLeftText, green); // Green sprintf(UpperLeftText, "Date: "); drawText(buf, w, h, 5, 14, UpperLeftText, green); // Green sprintf(UpperLeftText, "Time: "); drawText(buf, w, h, 5, 23, UpperLeftText, green); // Green } void drawMidLeftText(uint16_t* buf, int w, int h) { char MidLeftText[32]; sprintf(MidLeftText, "Spd: "); drawText(buf, w, h, 22, 116, MidLeftText, green); // Green } void drawMidRightText(uint16_t* buf, int w, int h) { char MidRightText[32]; sprintf(MidRightText, "Ele: "); int len = strlen(MidRightText); drawText(buf, w, h, w - len * 6 - 22, 116, MidRightText, green); // Green } void drawLowerLeftText(uint16_t* buf, int w, int h) { char LowerLeftText[32]; sprintf(LowerLeftText, "ESP32-CAM_Video_ver14c"); drawText(buf, w, h, 5, h - 21, LowerLeftText, green); // Green sprintf(LowerLeftText, "June 28 2025"); drawText(buf, w, h, 5, h - 12, LowerLeftText, green); // Green } void drawLowerRightText(uint16_t* buf, int w, int h) { char LowerRightText[32]; unsigned long ms = millis(); unsigned long total_seconds = ms / 1000; unsigned long hours = total_seconds / 3600; unsigned long minutes = (total_seconds % 3600) / 60; unsigned long seconds = total_seconds % 60; unsigned long tenths = (ms % 1000) / 100; // Tenths of a second // Format as "HH:MM:SS.S" sprintf(LowerRightText, "Uptime: %02lu:%02lu:%02lu.%1lu", hours, minutes, seconds, tenths); int len = strlen(LowerRightText); drawText(buf, w, h, w - len * 6 - 5, h - 12, LowerRightText, 0x07E0); // Green // Use Pitch values for temperature in degrees C char pitchStr[16]; dtostrf(pitch, 6, 2, pitchStr); // Width: 6, Precision: 2 (e.g., " 12.34") sprintf(LowerRightText, "Temp: %s%c C", pitchStr, 123); // 123 is the index for ° symbol len = strlen(LowerRightText); drawText(buf, w, h, w - len * 6 - 5, h - 21, LowerRightText, green); // Green } // ------------------- JPEG SAVE ------------------- void saveAsJPEG(uint16_t* rgb565_buf, int w, int h, const char *path) { File file = SD_MMC.open(path, FILE_WRITE); if (!file) { return; } // Convert RGB565 to RGB888 uint8_t *rgb888_buf = (uint8_t*)ps_malloc(w * h * 3); if (!rgb888_buf) { return; } for (int i = 0; i < w * h; i++) { uint16_t pixel = rgb565_buf[i]; uint8_t r = ((pixel >> 11) & 0x1F) * 255 / 31; uint8_t g = ((pixel >> 5) & 0x3F) * 255 / 63; uint8_t b = (pixel & 0x1F) * 255 / 31; rgb888_buf[i * 3 + 0] = r; rgb888_buf[i * 3 + 1] = g; rgb888_buf[i * 3 + 2] = b; } uint8_t *jpg_buf = NULL; size_t jpg_len = 0; // JPEG Quality at 100% if (!fmt2jpg(rgb888_buf, w * h * 3, w, h, PIXFORMAT_RGB888, 100, &jpg_buf, &jpg_len)) { free(rgb888_buf); file.close(); return; } file.write(jpg_buf, jpg_len); file.close(); free(jpg_buf); free(rgb888_buf); }
This writes a series of images to the microSD media which I then used this script to create a video using FFMpeg.
#!/bin/bash # Directory containing image frames INPUT_DIR="./microSD" # <-- Change to your desired folder OSD_VIDEO="ESP32-Cam_HUD-Demo.mp4" FRAMERATE=30 # Set desired FPS for the output video # Check if the input directory exists if [ ! -d "$INPUT_DIR" ]; then echo "Error: Directory $INPUT_DIR not found." exit 1 fi # Create video from ordered image sequence ffmpeg -framerate $FRAMERATE -pattern_type glob -i "${INPUT_DIR}/frame_sim_*.jpg" \ -c:v libx264 -pix_fmt yuv420p -crf 23 -preset medium "$OSD_VIDEO" echo "Video saved to $OSD_VIDEO"
If you’re still here, then here is the code to generate the text and color pattern during my debug. I used a modified version of the FFMpeg script above to make a video out of the image sequence.
/* ESP32-CAM_Video_ver15 This program captures a text pattern to test font characters and colors The code was developed using ChatGPT Edited by Patrick Gilfeather June 28 2025 Software used - Arduino IDE version 1.8.19 - ESP32 core version 2.0.4 - Board AI Thinker ESP32-CAM - Partition Scheme Huge APP (3MB No OTA) Hardware used - Espressif ESP32-Cam module - SanDisk Ultra 64GB microSD class 10 format FAT (32-bit version) */ #include "FS.h" #include "SD_MMC.h" #include "esp_camera.h" #include "img_converters.h" #define FRAME_WIDTH 320 #define FRAME_HEIGHT 240 uint32_t frameCounter = 0; #define RGB565(r8, g8, b8) ( ((r8 & 0xF8) << 8) | ((g8 & 0xFC) << 3) | (b8 >> 3) ) // The format follows this (blue, green, red) uint16_t black = RGB565(0, 0, 0); // Black uint16_t white = RGB565(255, 255, 255); // White uint16_t blue = RGB565(255, 0, 0); // Blue uint16_t green = RGB565(0, 255, 0); // Green uint16_t red = RGB565(0, 0, 255); // Red uint16_t yellow = RGB565(0, 255, 255); // Yellow uint16_t cyan = RGB565(255, 255, 0); // Cyan uint16_t magenta = RGB565(255, 0, 255); // Magenta uint16_t orange = RGB565(0, 128, 255); // Orange uint16_t brown = RGB565(32, 32, 128); // Brown uint16_t gray = RGB565(128, 128, 128); // Gray static const uint8_t font5x7[][5] = { // ASCII 32 to 123 (printable characters) {0x00,0x00,0x00,0x00,0x00}, // = 32 {0x00,0x00,0x5F,0x00,0x00}, // ! = 33 {0x00,0x07,0x00,0x07,0x00}, // " = 34 {0x14,0x7F,0x14,0x7F,0x14}, // # = 35 {0x24,0x2A,0x7F,0x2A,0x12}, // $ = 36 {0x23,0x13,0x08,0x64,0x62}, // % = 37 {0x36,0x49,0x55,0x22,0x50}, // & = 38 {0x00,0x05,0x03,0x00,0x00}, // ' = 39 {0x00,0x1C,0x22,0x41,0x00}, // ( = 40 {0x00,0x41,0x22,0x1C,0x00}, // ) = 41 {0x14,0x08,0x3E,0x08,0x14}, // * = 42 {0x08,0x08,0x3E,0x08,0x08}, // + = 43 {0x00,0x50,0x30,0x00,0x00}, // , = 44 {0x08,0x08,0x08,0x08,0x08}, // - = 45 {0x00,0x60,0x60,0x00,0x00}, // . = 46 {0x20,0x10,0x08,0x04,0x02}, // / = 47 {0x3E,0x51,0x49,0x45,0x3E}, // 0 = 48 {0x00,0x42,0x7F,0x40,0x00}, // 1 = 49 {0x42,0x61,0x51,0x49,0x46}, // 2 = 50 {0x21,0x41,0x45,0x4B,0x31}, // 3 = 51 {0x18,0x14,0x12,0x7F,0x10}, // 4 = 52 {0x27,0x45,0x45,0x45,0x39}, // 5 = 53 {0x3C,0x4A,0x49,0x49,0x30}, // 6 = 54 {0x01,0x71,0x09,0x05,0x03}, // 7 = 55 {0x36,0x49,0x49,0x49,0x36}, // 8 = 56 {0x06,0x49,0x49,0x29,0x1E}, // 9 = 57 {0x00,0x36,0x36,0x00,0x00}, // : = 58 {0x00,0x56,0x36,0x00,0x00}, // ; = 59 {0x08,0x14,0x22,0x41,0x00}, // < = 60 {0x14,0x14,0x14,0x14,0x14}, // = = 61 {0x00,0x41,0x22,0x14,0x08}, // > = 62 {0x02,0x01,0x51,0x09,0x06}, // ? = 63 {0x32,0x49,0x79,0x41,0x3E}, // @ = 64 {0x7E,0x11,0x11,0x11,0x7E}, // A = 65 {0x7F,0x49,0x49,0x49,0x36}, // B = 66 {0x3E,0x41,0x41,0x41,0x22}, // C = 67 {0x7F,0x41,0x41,0x22,0x1C}, // D = 68 {0x7F,0x49,0x49,0x49,0x41}, // E = 69 {0x7F,0x09,0x09,0x09,0x01}, // F = 70 {0x3E,0x41,0x49,0x49,0x7A}, // G = 71 {0x7F,0x08,0x08,0x08,0x7F}, // H = 72 {0x00,0x41,0x7F,0x41,0x00}, // I = 73 {0x20,0x40,0x41,0x3F,0x01}, // J = 74 {0x7F,0x08,0x14,0x22,0x41}, // K = 75 {0x7F,0x40,0x40,0x40,0x40}, // L = 76 {0x7F,0x02,0x0C,0x02,0x7F}, // M = 77 {0x7F,0x04,0x08,0x10,0x7F}, // N = 78 {0x3E,0x41,0x41,0x41,0x3E}, // O = 79 {0x7F,0x09,0x09,0x09,0x06}, // P = 80 {0x3E,0x41,0x51,0x21,0x5E}, // Q = 81 {0x7F,0x09,0x19,0x29,0x46}, // R = 82 {0x46,0x49,0x49,0x49,0x31}, // S = 83 {0x01,0x01,0x7F,0x01,0x01}, // T = 84 {0x3F,0x40,0x40,0x40,0x3F}, // U = 85 {0x1F,0x20,0x40,0x20,0x1F}, // V = 86 {0x3F,0x40,0x38,0x40,0x3F}, // W = 87 {0x63,0x14,0x08,0x14,0x63}, // X = 88 {0x07,0x08,0x70,0x08,0x07}, // Y = 89 {0x61,0x51,0x49,0x45,0x43}, // Z = 90 {0x00,0x7F,0x41,0x41,0x00}, // [ = 91 {0x02,0x04,0x08,0x10,0x20}, // \ = 92 {0x00,0x41,0x41,0x7F,0x00}, // ] = 93 {0x04,0x02,0x01,0x02,0x04}, // ^ = 94 {0x40,0x40,0x40,0x40,0x40}, // _ = 95 {0x00,0x01,0x02,0x04,0x00}, // ` = 96 {0x20,0x54,0x54,0x54,0x78}, // a = 97 {0x7F,0x48,0x44,0x44,0x38}, // b = 98 {0x38,0x44,0x44,0x44,0x20}, // c = 99 {0x38,0x44,0x44,0x48,0x7F}, // d = 100 {0x38,0x54,0x54,0x54,0x18}, // e = 101 {0x08,0x7E,0x09,0x01,0x02}, // f = 102 {0x0C,0x52,0x52,0x52,0x3E}, // g = 103 {0x7F,0x08,0x04,0x04,0x78}, // h = 104 {0x00,0x44,0x7D,0x40,0x00}, // i = 105 {0x20,0x40,0x44,0x3D,0x00}, // j = 106 {0x7F,0x10,0x28,0x44,0x00}, // k = 107 {0x00,0x41,0x7F,0x40,0x00}, // l = 108 {0x7C,0x04,0x18,0x04,0x78}, // m = 109 {0x7C,0x08,0x04,0x04,0x78}, // n = 110 {0x38,0x44,0x44,0x44,0x38}, // o = 111 {0x7C,0x14,0x14,0x14,0x08}, // p = 112 {0x08,0x14,0x14,0x18,0x7C}, // q = 113 {0x7C,0x08,0x04,0x04,0x08}, // r = 114 {0x48,0x54,0x54,0x54,0x20}, // s = 115 {0x04,0x3F,0x44,0x40,0x20}, // t = 116 {0x3C,0x40,0x40,0x20,0x7C}, // u = 117 {0x1C,0x20,0x40,0x20,0x1C}, // v = 118 {0x3C,0x40,0x30,0x40,0x3C}, // w = 119 {0x44,0x28,0x10,0x28,0x44}, // x = 120 {0x0C,0x50,0x50,0x50,0x3C}, // y = 121 {0x44,0x64,0x54,0x4C,0x44}, // z = 122 {0x06,0x09,0x09,0x06,0x00} // ° = 123 }; // ------------------- SETUP ------------------- void setup() { delay(10); if (!SD_MMC.begin()) { return; } } // ------------------- LOOP ------------------- void loop() { uint16_t* buffer = createBlackFrameRGB565(); if (!buffer) return; drawAllCharacters(buffer, FRAME_WIDTH, FRAME_HEIGHT, frameCounter); drawLowerLeftText(buffer, FRAME_WIDTH, FRAME_HEIGHT); drawLowerRightText(buffer, FRAME_WIDTH, FRAME_HEIGHT); char filename[32]; sprintf(filename, "/frame_sim_%05lu.jpg", frameCounter); saveAsJPEG(buffer, FRAME_WIDTH, FRAME_HEIGHT, filename); frameCounter++; free(buffer); } // ------------------- FRAME CREATION ------------------- uint16_t* createBlackFrameRGB565() { size_t bufferSize = FRAME_WIDTH * FRAME_HEIGHT * sizeof(uint16_t); uint16_t* buffer = (uint16_t*)ps_malloc(bufferSize); if (!buffer) { return nullptr; } memset(buffer, 0x00, bufferSize); return buffer; } // ------------------- TEXT ------------------- void drawChar(uint16_t* buf, int w, int h, int x, int y, char c, uint16_t color) { const uint8_t* chr = nullptr; if (c >= 32 && c <= 123) chr = font5x7[c - 32]; else return; for (int col = 0; col < 5; col++) { uint8_t line = chr[col]; for (int row = 0; row < 7; row++) { if (line & (1 << row)) { int px = x + col; int py = y + row; if (px >= 0 && px < w && py >= 0 && py < h) buf[py * w + px] = color; } } } } void drawAllCharacters(uint16_t* buf, int w, int h, int startCharOffset) { const int startX = 5; const int startY = 5; const int charWidth = 6; // 5 pixels + 1 spacing const int lineHeight = 9; // 7 pixels + 2 spacing uint16_t colors[] = { blue, cyan, white, yellow, orange, red, gray, brown, green, magenta }; const int numColors = sizeof(colors) / sizeof(colors[0]); const int firstChar = 32; const int lastChar = 123; const int numChars = lastChar - firstChar + 1; // Clamp startCharOffset into valid range startCharOffset %= numChars; int x = startX; int y = startY; // Full sweep through colors for (int i = 0; i < numColors; i++) { // Draw from startCharOffset .. lastChar for (int j = startCharOffset; j < numChars; j++) { int c = firstChar + j; drawChar(buf, w, h, x, y, c, colors[i]); x += charWidth; if (x + charWidth > w) { x = startX; y += lineHeight; if (y + 7 > h) return; // Stop if we run out of space } } // Draw from firstChar .. startCharOffset-1 for (int j = 0; j < startCharOffset; j++) { int c = firstChar + j; drawChar(buf, w, h, x, y, c, colors[i]); x += charWidth; if (x + charWidth > w) { x = startX; y += lineHeight; if (y + 7 > h) return; // Stop if we run out of space } } } } void drawText(uint16_t* buf, int w, int h, int x, int y, const char* text, uint16_t color) { while (*text) { drawChar(buf, w, h, x, y, *text++, color); x += 6; } } void drawLowerLeftText(uint16_t* buf, int w, int h) { char LowerLeftText[32]; sprintf(LowerLeftText, "ESP32-CAM_Video_ver15"); drawText(buf, w, h, 5, h - 21, LowerLeftText, white); sprintf(LowerLeftText, "June 28 2025"); drawText(buf, w, h, 5, h - 12, LowerLeftText, white); } void drawLowerRightText(uint16_t* buf, int w, int h) { char LowerRightText[32]; unsigned long ms = millis(); unsigned long total_seconds = ms / 1000; unsigned long hours = total_seconds / 3600; unsigned long minutes = (total_seconds % 3600) / 60; unsigned long seconds = total_seconds % 60; unsigned long tenths = (ms % 1000) / 100; // Tenths of a second // Format as "HH:MM:SS.S" sprintf(LowerRightText, "Uptime: %02lu:%02lu:%02lu.%1lu", hours, minutes, seconds, tenths); int len = strlen(LowerRightText); drawText(buf, w, h, w - len * 6 - 5, h - 12, LowerRightText, white); // } // ------------------- JPEG SAVE ------------------- void saveAsJPEG(uint16_t* rgb565_buf, int w, int h, const char *path) { File file = SD_MMC.open(path, FILE_WRITE); if (!file) { return; } // Convert RGB565 to RGB888 uint8_t *rgb888_buf = (uint8_t*)ps_malloc(w * h * 3); if (!rgb888_buf) { return; } for (int i = 0; i < w * h; i++) { uint16_t pixel = rgb565_buf[i]; uint8_t r = ((pixel >> 11) & 0x1F) * 255 / 31; uint8_t g = ((pixel >> 5) & 0x3F) * 255 / 63; uint8_t b = (pixel & 0x1F) * 255 / 31; rgb888_buf[i * 3 + 0] = r; rgb888_buf[i * 3 + 1] = g; rgb888_buf[i * 3 + 2] = b; } uint8_t *jpg_buf = NULL; size_t jpg_len = 0; // JPG Quality 100% if (!fmt2jpg(rgb888_buf, w * h * 3, w, h, PIXFORMAT_RGB888, 100, &jpg_buf, &jpg_len)) { free(rgb888_buf); file.close(); return; } file.write(jpg_buf, jpg_len); file.close(); free(jpg_buf); free(rgb888_buf); }
If you think AI is going to spoon feed you the answer, you’re in for a scare if you have a deadline looming over your head.