This guide explains how to interface a Raspberry Pi-compatible CSI camera with the FireBeetle 2 ESP32 P4 development board using the ESP-IDF framework.
Â
Per the official FireBeetle 2 ESP32 P4 Wiki, the board features a CSI camera interface compatible with Raspberry Pi 4B. This allows seamless integration with cameras from the Raspberry Pi ecosystem without additional configuration.
Â
For this project, DFRobot provided a compatible CSI camera, which connects directly to the board’s CSI interface.
Â

Â

Â
When connecting, use a 15-pin, 1.0mm pitch FPC cable with the silver side facing the contact points and the blue side toward the black locking clip.
Â
Building on a previous project where an ST7789 display was interfaced, this guide focuses on capturing video frames from the CSI camera and rendering them on the ST7789 display.
Â
The camera’s resolution differs from the display’s, so captured frames are scaled before rendering. The CSI camera captures data, processes it via a callback function, and places it into a queue. This simplifies the main program loop, which only needs to initialize and start the camera capture.
Â
Below is the relevant code for initializing the display and handling screen rotation:
Â
// Screen rotation static void lcd_rotate(uint16_t rotation) { switch (rotation) { case 0: esp_lcd_panel_swap_xy(panel_handle, false); esp_lcd_panel_mirror(panel_handle, !CAMERA_SELFIE_MODE, false); break; case 90: esp_lcd_panel_swap_xy(panel_handle, true); esp_lcd_panel_mirror(panel_handle, CAMERA_SELFIE_MODE, false); break; case 180: esp_lcd_panel_swap_xy(panel_handle, false); esp_lcd_panel_mirror(panel_handle, CAMERA_SELFIE_MODE, true); break; case 270: esp_lcd_panel_swap_xy(panel_handle, true); esp_lcd_panel_mirror(panel_handle, !CAMERA_SELFIE_MODE, true); break; } } // Initialize ST7789 display static void init_lcd_display(void) { // Configure backlight GPIO gpio_config_t bk_gpio_config = { .mode = GPIO_MODE_OUTPUT, .pin_bit_mask = 1ULL << LCD_PIN_NUM_BK_LIGHT }; ESP_ERROR_CHECK(gpio_config(&bk_gpio_config)); gpio_set_level(LCD_PIN_NUM_BK_LIGHT, LCD_DISP_BK_LIGHT_OFF_LEVEL); // Initialize SPI bus spi_bus_config_t buscfg = { .sclk_io_num = LCD_PIN_NUM_SCLK, .mosi_io_num = LCD_PIN_NUM_MOSI, .miso_io_num = LCD_PIN_NUM_MISO, .quadwp_io_num = -1, .quadhd_io_num = -1, .max_transfer_sz = LCD_DISP_H_RES * LCD_DISP_V_RES * sizeof(uint16_t), }; ESP_ERROR_CHECK(spi_bus_initialize(LCD_HOST, &buscfg, SPI_DMA_CH_AUTO)); // Install panel I/O esp_lcd_panel_io_handle_t io_handle = NULL; esp_lcd_panel_io_spi_config_t io_config = { .dc_gpio_num = LCD_PIN_NUM_LCD_DC, .cs_gpio_num = LCD_PIN_NUM_LCD_CS, .pclk_hz = LCD_DISP_PIXEL_CLOCK_HZ, .lcd_cmd_bits = 8, .lcd_param_bits = 8, .spi_mode = 0, .trans_queue_depth = 10, }; ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)LCD_HOST, &io_config, &io_handle)); // Create ST7789 panel esp_lcd_panel_dev_config_t panel_config = { .reset_gpio_num = LCD_PIN_NUM_LCD_RST, .rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB, .bits_per_pixel = 16, }; ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(io_handle, &panel_config, &panel_handle)); // Reset and initialize panel ESP_ERROR_CHECK(esp_lcd_panel_reset(panel_handle)); ESP_ERROR_CHECK(esp_lcd_panel_init(panel_handle)); ESP_ERROR_CHECK(esp_lcd_panel_invert_color(panel_handle, false)); ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel_handle, true)); // Turn on backlight gpio_set_level(LCD_PIN_NUM_BK_LIGHT, LCD_DISP_BK_LIGHT_ON_LEVEL); }The main application flow is as follows:
void app_main(void) { ESP_LOGI(TAG, "Application starting..."); // 1. Initialize LCD display init_lcd_display(); lcd_rotate(LCD_DISP_ROTATE); ESP_LOGI(TAG, "LCD initialized"); // 2. Allocate scaling buffer init_scaling_params(); scaled_buffer = heap_caps_malloc(LCD_DISP_H_RES * LCD_DISP_V_RES * sizeof(uint16_t), MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL); assert(scaled_buffer != NULL); ESP_LOGI(TAG, "Scaled buffer allocated: %d bytes", LCD_DISP_H_RES * LCD_DISP_V_RES * sizeof(uint16_t)); // 3. Create display queue display_queue = xQueueCreate(5, sizeof(uint8_t*)); assert(display_queue != NULL); ESP_LOGI(TAG, "Display queue created"); // 4. Create display task xTaskCreate(display_task, "display_task", 4096, NULL, 8, NULL); ESP_LOGI(TAG, "Display task created"); // 5. Initialize CSI camera cam_buffer_size = CSI_MIPI_CSI_DISP_HRES * CSI_MIPI_CSI_DISP_VRES * 2; // RGB565: 2 bytes per pixel ESP_LOGI(TAG, "Camera buffer size: %d bytes", cam_buffer_size); // Initialize MIPI LDO esp_ldo_channel_handle_t ldo_mipi_phy = NULL; esp_ldo_channel_config_t ldo_config = { .chan_id = CSI_USED_LDO_CHAN_ID, .voltage_mv = CSI_USED_LDO_VOLTAGE_MV, }; ESP_ERROR_CHECK(esp_ldo_acquire_channel(&ldo_config, &ldo_mipi_phy)); ESP_LOGI(TAG, "LDO initialized"); // Allocate camera frame buffers size_t frame_buffer_alignment = 0; ESP_ERROR_CHECK(esp_cache_get_alignment(0, &frame_buffer_alignment)); for (int i = 0; i < NUM_CAM_BUFFERS; i++) { cam_buffers[i] = heap_caps_aligned_calloc(frame_buffer_alignment, 1, cam_buffer_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_DMA); assert(cam_buffers[i] != NULL); ESP_LOGI(TAG, "Camera buffer %d allocated: %p", i, cam_buffers[i]); } ESP_LOGI(TAG, "%d camera buffers allocated", NUM_CAM_BUFFERS); // Initialize camera sensor #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) example_sensor_handle_t sensor_handle; #else i2c_master_bus_handle_t sensor_handle; #endif example_sensor_config_t sensor_config = { .i2c_port_num = I2C_NUM_0, .i2c_sda_io_num = CSI_MIPI_CSI_CAM_SCCB_SDA_IO, .i2c_scl_io_num = CSI_MIPI_CSI_CAM_SCCB_SCL_IO, .port = ESP_CAM_SENSOR_MIPI_CSI, .format_name = CSI_CAM_FORMAT, }; ESP_ERROR_CHECK(example_sensor_init(&sensor_config, &sensor_handle)); ESP_LOGI(TAG, "Camera sensor initialized"); // Initialize CSI controller esp_cam_ctlr_csi_config_t csi_config = { .ctlr_id = 0, .h_res = CSI_MIPI_CSI_DISP_HRES, .v_res = CSI_MIPI_CSI_DISP_VRES, .lane_bit_rate_mbps = CSI_MIPI_CSI_LANE_BITRATE_MBPS, .input_data_color_type = CAM_CTLR_COLOR_RAW8, .output_data_color_type = CAM_CTLR_COLOR_RGB565, .data_lane_num = 2, .byte_swap_en = false, .queue_items = 1, }; esp_cam_ctlr_handle_t cam_handle = NULL; ESP_ERROR_CHECK(esp_cam_new_csi_ctlr(&csi_config, &cam_handle)); ESP_LOGI(TAG, "CSI controller initialized"); // Register event callbacks esp_cam_ctlr_evt_cbs_t cbs = { .on_get_new_trans = camera_get_new_buffer, .on_trans_finished = camera_trans_finished, }; ESP_ERROR_CHECK(esp_cam_ctlr_register_event_callbacks(cam_handle, &cbs, NULL)); ESP_ERROR_CHECK(esp_cam_ctlr_enable(cam_handle)); ESP_LOGI(TAG, "Camera event callbacks registered"); // Initialize ISP processor isp_proc_handle_t isp_proc = NULL; esp_isp_processor_cfg_t isp_config = { .clk_hz = 80 * 1000 * 1000, .input_data_source = ISP_INPUT_DATA_SOURCE_CSI, .input_data_color_type = ISP_COLOR_RAW8, .output_data_color_type = ISP_COLOR_RGB565, .has_line_start_packet = true, .has_line_end_packet = true, .h_res = CSI_MIPI_CSI_DISP_HRES, .v_res = CSI_MIPI_CSI_DISP_VRES, }; ESP_ERROR_CHECK(esp_isp_new_processor(&isp_config, &isp_proc)); ESP_ERROR_CHECK(esp_isp_enable(isp_proc)); ESP_LOGI(TAG, "ISP processor initialized"); // Start camera capture ESP_ERROR_CHECK(esp_cam_ctlr_start(cam_handle)); ESP_LOGI(TAG, "Camera capture started"); // Main loop - monitor performance int64_t last_log_time = esp_timer_get_time(); while (1) { vTaskDelay(pdMS_TO_TICKS(1000)); int64_t now = esp_timer_get_time(); if (now - last_log_time > 1000000) { ESP_LOGI(TAG, "Frames processed: %d, Queue depth: %d", trans_finished_count, uxQueueMessagesWaiting(display_queue)); trans_finished_count = 0; last_log_time = now; } } // Cleanup (unreachable in this example) ESP_ERROR_CHECK(esp_cam_ctlr_stop(cam_handle)); ESP_ERROR_CHECK(esp_cam_ctlr_disable(cam_handle)); ESP_ERROR_CHECK(esp_cam_ctlr_del(cam_handle)); ESP_ERROR_CHECK(esp_isp_disable(isp_proc)); ESP_ERROR_CHECK(esp_isp_del_processor(isp_proc)); ESP_ERROR_CHECK(esp_ldo_release_channel(ldo_mipi_phy)); #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) example_sensor_deinit(sensor_handle); #endif for (int i = 0; i < NUM_CAM_BUFFERS; i++) { heap_caps_free(cam_buffers[i]); } heap_caps_free(scaled_buffer); vQueueDelete(display_queue); }The camera captures frames and processes them through event callbacks:
/ Camera event callbacks static bool IRAM_ATTR camera_get_new_buffer(esp_cam_ctlr_handle_t handle, esp_cam_ctlr_trans_t *trans, void *user_data) { static int buffer_index = 0; trans->buffer = cam_buffers[buffer_index]; trans->buflen = cam_buffer_size; buffer_index = (buffer_index + 1) % NUM_CAM_BUFFERS; return false; } static bool IRAM_ATTR camera_trans_finished(esp_cam_ctlr_handle_t handle, esp_cam_ctlr_trans_t *trans, void *user_data) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; xQueueSendFromISR(display_queue, &trans->buffer, &xHigherPriorityTaskWoken); trans_finished_count++; return xHigherPriorityTaskWoken == pdTRUE; }The display operates in a separate task, processing frames as follows:
// Initialize scaling parameters static void init_scaling_params(void) { src_width = CSI_MIPI_CSI_DISP_HRES; src_height = CSI_MIPI_CSI_DISP_VRES; dst_width = LCD_DISP_H_RES; dst_height = LCD_DISP_V_RES; // Calculate scaling ratio (use the smaller of width or height ratio) const float width_ratio = (float)dst_width / src_width; const float height_ratio = (float)dst_height / src_height; scale = (width_ratio < height_ratio) ? width_ratio : height_ratio; // Calculate scaled dimensions scaled_width = (int)(src_width * scale); scaled_height = (int)(src_height * scale); // Calculate center offsets x_offset = (dst_width - scaled_width) / 2; y_offset = (dst_height - scaled_height) / 2; } // Scale image using nearest-neighbor interpolation static void IRAM_ATTR scale_image(uint16_t *src, uint16_t *dst) { memset(dst, 0x00, dst_width * dst_height * sizeof(uint16_t)); for (int y = 0; y < scaled_height; y++) { for (int x = 0; x < scaled_width; x++) { const int src_x = (int)(x / scale); const int src_y = (int)(y / scale); const int safe_src_x = (src_x < src_width) ? src_x : src_width - 1; const int safe_src_y = (src_y < src_height) ? src_y : src_height - 1; dst[(y + y_offset) * dst_width + (x + x_offset)] = __builtin_bswap16(src[safe_src_y * src_width + safe_src_x]); } } } // Display processing task void display_task(void *arg) { uint8_t *frame_data; int64_t last_frame_time = esp_timer_get_time(); while (1) { if (xQueueReceive(display_queue, &frame_data, pdMS_TO_TICKS(50))) { // Process only the latest frame while (uxQueueMessagesWaiting(display_queue) > 0) { xQueueReceive(display_queue, &frame_data, 0); } // Scale the image int64_t start = esp_timer_get_time(); scale_image((uint16_t*)frame_data, scaled_buffer); int64_t scale_time = esp_timer_get_time() - start; // Draw to LCD start = esp_timer_get_time(); esp_lcd_panel_draw_bitmap(panel_handle, 0, 0, LCD_DISP_H_RES, LCD_DISP_V_RES, scaled_buffer); int64_t draw_time = esp_timer_get_time() - start; // Log performance int64_t now = esp_timer_get_time(); int64_t frame_interval = now - last_frame_time; last_frame_time = now; ESP_LOGI(TAG, "Frame processed: scale=%lldµs, draw=%lldµs, interval=%lldµs", scale_time, draw_time, frame_interval); } } }The workflow integrates as follows: The esp_cam_ctlr_register_event_callbacks() function registers camera callbacks. When a frame is captured, camera_get_new_buffer() assigns a buffer, and camera_trans_finished() queues the data. The display_task() retrieves frames from the queue, scales them using scale_image(), and renders them with esp_lcd_panel_draw_bitmap().
A config.h file centralizes the configuration:
#ifndef _CONFIG_H_ #define _CONFIG_H_ // Camera configuration #define CSI_USED_LDO_CHAN_ID 3 #define CSI_USED_LDO_VOLTAGE_MV 2500 #define CSI_RGB565_BITS_PER_PIXEL 16 #define CSI_MIPI_CSI_LANE_BITRATE_MBPS 200 #define CSI_MIPI_CSI_CAM_SCCB_SCL_IO 8 #define CSI_MIPI_CSI_CAM_SCCB_SDA_IO 7 #define CSI_MIPI_CSI_DISP_HRES 800 #define CSI_MIPI_CSI_DISP_VRES 640 #define CSI_CAM_FORMAT "MIPI_2lane_24Minput_RAW8_800x640_50fps" // Display configuration (ST7789) #define LCD_HOST SPI2_HOST #define LCD_PIN_NUM_SCLK 4 #define LCD_PIN_NUM_MOSI 5 #define LCD_PIN_NUM_MISO -1 #define LCD_PIN_NUM_LCD_DC 21 #define LCD_PIN_NUM_LCD_RST 20 #define LCD_PIN_NUM_LCD_CS 22 #define LCD_PIN_NUM_BK_LIGHT 23 #define LCD_DISP_H_RES 240 #define LCD_DISP_V_RES 320 #define LCD_DISP_PIXEL_CLOCK_HZ (20 * 1000 * 1000) #define LCD_DISP_BK_LIGHT_ON_LEVEL 1 #define LCD_DISP_BK_LIGHT_OFF_LEVEL !LCD_DISP_BK_LIGHT_ON_LEVEL #define LCD_DISP_ROTATE 0 #define CAMERA_SELFIE_MODE true // Double buffering #define NUM_CAM_BUFFERS 2 #endif // _CONFIG_H_In addition to the adaptations in the code, configuration is also required in menuconfig, as detailed below:
Â

Â
Ensure CSI_MIPI_CSI_DISP_HRES and CSI_MIPI_CSI_DISP_VRES match the selected CSI_CAM_FORMAT in config.h. The OV5647 camera supports the following modes:
Â
MIPI_2lane_24Minput_RAW8_800x1280_50fps
MIPI_2lane_24Minput_RAW8_800x640_50fps
MIPI_2lane_24Minput_RAW8_800x800_50fps
MIPI_2lane_24Minput_RAW10_1920x1080_30fps
MIPI_2lane_24Minput_RAW10_1280x960_binning_45fps
Â
Select the appropriate mode for your application. Additionally, configure the ESP-IDF project settings via menuconfig to align with the hardware setup.
Â
Compile and flash the code using:
Â
idf.py build flash monitorThe system successfully captures and displays video frames.
Â

Â
Testing with an older Raspberry Pi camera yielded comparable results, confirming compatibility.
Â

Â
