
EDA-Camera照相机
简介
基于立创开发板ESP32S3R8N8构建,支持500万像素的卡片照相机,支持局域网相册管理,可自由选择OV2640/OV5640,贴片阻容采用0805封装,适合初学者焊接。
简介:基于立创开发板ESP32S3R8N8构建,支持500万像素的卡片照相机,支持局域网相册管理,可自由选择OV2640/OV5640,贴片阻容采用0805封装,适合初学者焊接。开源协议
:GPL 3.0
描述
项目简介🪄
本项目基于搭载双核RISC处理器的【立创开发板 ESP32S3R8N8】构建,是一个支持500万像素的卡片照相机,支持局域网相册管理,采用模块化CMOS传感器,可自由选择OV2640/OV5640模块。贴片阻容采用0805封装,适合初学者焊接。
项目功能
立创开发板ESP32S3R8N8开发板具有8MB PSRAM 和 8MB FLASH,得益于8MB 的 PSRAM,本项目中摄像头使用了多帧缓存,使得实时画面拥有较高的FPS帧率,同时在屏幕显示中也采用了双画布缓存,避免图文元素和摄像头画面重叠闪烁。同时借助FreeRTOS实时操作系统实现复杂任务处理。
- ✅ 使用FreeRTOS Kernel V10.4.3 实时操作系统,多核多任务并发
- ✅ 支持摄像头多帧缓存及屏幕双缓冲区
- ✅ 支持500万像素CMOS采集,高达2.5K分辨率的超清图像
- ✅ 支持局域网文件管理,无需读卡器即可导出至手机等其他设备中
- ✅ 支持本地图像预览
- ✅ 支持7种特殊色彩滤镜
- ✅ 支持画面亮度,对比度,饱和度调节
- ✅ 错误报告,未插入摄像头、TF卡时提示
项目参数🔮
硬件设计

(选用屏幕时切记确保屏幕线序与PCB焊盘线序匹配)
| ST7789屏幕模组模组 | 原理图 |
|---|---|
![]() | ![]() |
(本项目未添加触控功能,无需购买带触控面板的屏幕,当然你可以自己拓展触控功能)
| 屏幕信息 | 引脚定义 |
|---|---|
![]() | ![]() |
(选用模块时切记确保模块线序与PCB接口线序匹配)
| OV2640/OV5640模组 | 原理图 |
|---|---|
![]() | ![]() |
CMOS模组内LDO前级有滤波电容,所以这里原理图模组供电端就不用加滤波电容了
电池供电电路中选用TP4056锂电池充电芯片,电池选用14500两节并联满电电压4.2V锂电池。项目中通过开发板上的TYPEC接口的5V进行充放电,没有单独设立充电TypeC接口,通过电源开关,因此只能开机充电,建议充电模式下外部电缆能提供5V2A10W功率,确保既能为设备供电又能为电池充电。
(项目电路没有添加保护功能,选用电池时确认电池带保护电路板)
(TP4056选用1A挡位充电时建议添加散热片)

(为方便没有加热台和风枪的用户焊接TP4056,在焊盘背面做了开窗和插件焊盘孔,你可以在孔内插入锡丝并用烙铁融化即可)
| 焊盘正面 | 焊盘背面 |
|---|---|
![]() | ![]() |
| 电池 | 开关 |
![]() | ![]() |
电池端口这里采用了一颗硝基特二极管来防止电池供电挡位下还外接Type-C供电造成5V电压直连电池的过压充电情况,但这会导致产生0.2V压降,这里有两种解决办法:
1.如果你的电池带过充保护,可以考虑去掉这颗二极管并短接。
2.采用AI-Box或泰山派的防反接电路。
其他外设中添加有补光灯和按键功能,由于高亮闪光灯需要更大的电流,发热量也较大,而且需要导光柱将光线导至外部,所以这里直接用普通LED代替。你可以自行修改使用高亮灯珠。

原理图中我们列举了所有总线节点,在设计中通常使用0R电阻方便调试,这里已经完成了验证就使用短接符代替了。
| 节点树 | 节点树 |
|---|---|
![]() | ![]() |
本项目中大部分外设供电均由板载ME6217C33M5G 3.3V LDO提供降压稳压供电,ME6217C33M5G最大供电能力800mA,本项目中我们通过功耗表测量整机供应电流为320mA-360mA,在LDO工作范围内,但在使用OV5640并板载TypeC供电时会出现LDO发热问题。
| OV5640预览状态下 | OV5640拍摄2.5K分辨率照片时 |
|---|---|
![]() | ![]() |
| 负载电流:322mA | 负载电流:365mA |
如果你使用的是OV5640 CMOS,建议为CMOS背面添加散热片
续航计算我们使用嘉立创EDA插件广场的 Toolbox工具箱 中原理图工具箱的续航计算器功能
这里我们只需要输入我们使用的电池容量≈2000mAh,负载电流≈350mAh,点击计算后即可得到理想状态下最大续航时间≈5.71小时,当然实际上受限于电池健康度,实时负载电流等的影响,续航时间会略低于这个值,ESP32S3支持最低3V供电,实测满电可运行约5小时。
| 插件名称 | 续航计算器界面 |
|---|---|
![]() | ![]() |
原理图计算器功能大全

软件开发
- 软件环境:VSCode+PlatformIO
- 开发语言:C/C++
- TFT_eSPI:用于屏幕显示驱动
- TJpg_Decoder:用于将JPEG格式转换为RGB565
内核版本:FreeRTOS Kernel V10.4.3
本项目中采用FreeRTOS操作系统实现多核多任务多线程并发任务调度,处理复杂任务场景。
相关任务及操作均封装成库,方便调用,统一管理
- cameraTask.h/.cpp:摄像头相关任务及配置
- displayTask.h/.cpp:屏幕显示相关任务及配置
- keyTask.h/.cpp:按键状态机及配置
- webTask.h/.cpp:局域网文件管理器任务及配置
- tfCard.h/.cpp:TF卡引脚定义及文件操作
- image.cpp:存储RGB565格式的图片数组
- config.h:配置文件
- main.cpp:主函数入口
命名:
在本项目所创建的库中,函数命名格式统一为 库名称+功能名 的格式命名,方便统一管理及查阅
如:cameraTask_Init()、displayTask_Gallery(int index)等,可以很清楚的知道执行函数所在库位置及功能。
注释:
本项目的头文件注释遵循Doxygen注释语法,方便管理及生成对应的开发文档。在编辑器中也可通过光标移动显示函数功能,用途等。
程序介绍这里只展示部分代码块,完整源码可前往在线文档WIKI或附件下载查看。(注:因Markdown中的部分C语言代码,如#include<>及部分HTML源码会被Markdown渲染器认为是HTML而解析导致不显示,所以完整的代码块内容建议到附件中查看源码文件)
CPP文件中主要是存放函数的实现,这里我们先看初始化cameraTask_InitCameraConfig()这个函数,在这个函数内我们通过ESP的#include "esp_camera.h"来完成摄像头配置,简化了很多底层操作,这样我们只用负责应用层开发。主要关注config.jpeg_quality、config.fb_count、config.fb_location这三个关键配置。
其中config.jpeg_quality定义的是画面成像的质量,0为最高画质,成像质量越高则会占用更高的RAM,很容易造成拍摄时内存爆出导致程序死机重启,建议根据硬件情况配置。config.fb_count则是指的需要分配的帧缓冲区数量,没有PSRAM建议设置成单缓冲,但我们开发板有8MB的PSRAM,所以这里我们设置2帧缓冲,可以提升1倍的预览帧率,但设置更多缓冲不会有明显变化,想要进一步提升则要超频或从高刷屏及DMA入手。
在摄像头任务void cameraTask(void *pvParameters)中我们使用队列来保护,避免在后续显示任务调用数据导致冲突或内存错误。
其次我们看cameraTask_InitCameraSoftwareConfig()函数,这里包含了摄像头的一些软件功能,比如白平衡、曝光、色调等等,如果你想打造更高级的专业相机自定义,可以和我一样通过传参到special实现按键调,不过不配置也没关系,会有一个默认配置生成,除非你想要更专业的画面。这个函数里我还配置了一个宏,市面上OV5640和OV2640的方形模块成像是上下翻转的,不过这并不唯一,具体的根据你的CMOS模块来定,如果你的CMOS模块比较特殊,可以去掉这个宏。
在头文件中,我们定义了摄像头引脚IO,以及为摄像头定义了两个初始化配置,一个是RGB565,一个是JPEG格式,我们的LCD屏幕要实现高帧率原生显示就必须用RGB565格式,而照片的预览格式通常又是JPG格式,所以这里的想法是在预览时使用RGB565,而当按下拍摄键时切换到JPEG格式,拍摄完成后再切回RGB565。
/**
* @file cameraTask.h
* @brief 摄像头任务管理
* @details 该文件包含了摄像头相关功能的声明
* @version 1.0
* @date 2025-6-30
*/
// CameraTask.cpp
#include "cameraTask.h"
#include "config.h"
#include "displayTask.h"
int special2 = 0;
int special = 0;
camera_config_t config; // 声明相机配置结构体
QueueHandle_t frameQueue; // 声明队列,用于存储预览画面帧数据
sensor_t *sensor;
// —— 预览画面配置(OV2640/兼容OV5640) ——
// 初始化预览画面配置,RGN565格式,同时设置分辨率为QVGA,与TFT屏幕push格式及分辨率一致
void cameraTask_InitCameraConfig()
{
config.ledc_channel = LEDC_CHANNEL_0;
config.ledc_timer = LEDC_TIMER_0;
config.pin_d0 = Y2_GPIO_NUM;
config.pin_d1 = Y3_GPIO_NUM;
config.pin_d2 = Y4_GPIO_NUM;
config.pin_d3 = Y5_GPIO_NUM;
config.pin_d4 = Y6_GPIO_NUM;
config.pin_d5 = Y7_GPIO_NUM;
config.pin_d6 = Y8_GPIO_NUM;
config.pin_d7 = Y9_GPIO_NUM;
config.pin_xclk = XCLK_GPIO_NUM;
config.pin_pclk = PCLK_GPIO_NUM;
config.pin_vsync = VSYNC_GPIO_NUM;
config.pin_href = HREF_GPIO_NUM;
config.pin_sccb_sda = SIOD_GPIO_NUM;
config.pin_sccb_scl = SIOC_GPIO_NUM;
config.pin_pwdn = PWDN_GPIO_NUM;
config.pin_reset = RESET_GPIO_NUM;
config.xclk_freq_hz = 20000000;
config.pixel_format = PIXFORMAT_RGB565;
config.frame_size = FRAMESIZE_QVGA;
config.jpeg_quality = 20;
config.fb_count = 2;
config.fb_location = CAMERA_FB_IN_PSRAM;
}
// —— 相机拍摄配置(OV2640/兼容OV5640) ——
// 初始化拍摄配置,JPEG格式,同时设置分辨率为HD,与png格式要求一致,屏幕比例下最大分辨率,在取得最佳质量下确保能通过屏幕预览画面
void cameraTask_InitPicConfig()
{
config.ledc_channel = LEDC_CHANNEL_0;
config.ledc_timer = LEDC_TIMER_0;
config.pin_d0 = Y2_GPIO_NUM;
config.pin_d1 = Y3_GPIO_NUM;
config.pin_d2 = Y4_GPIO_NUM;
config.pin_d3 = Y5_GPIO_NUM;
config.pin_d4 = Y6_GPIO_NUM;
config.pin_d5 = Y7_GPIO_NUM;
config.pin_d6 = Y8_GPIO_NUM;
config.pin_d7 = Y9_GPIO_NUM;
config.pin_xclk = XCLK_GPIO_NUM;
config.pin_pclk = PCLK_GPIO_NUM;
config.pin_vsync = VSYNC_GPIO_NUM;
config.pin_href = HREF_GPIO_NUM;
config.pin_sccb_sda = SIOD_GPIO_NUM;
config.pin_sccb_scl = SIOC_GPIO_NUM;
config.pin_pwdn = PWDN_GPIO_NUM;
config.pin_reset = RESET_GPIO_NUM;
config.xclk_freq_hz = 20000000;
config.pixel_format = PIXFORMAT_JPEG;
#if defined(OV2640)
config.frame_size = FRAMESIZE_SXGA; // 1280x1024
#elif defined(OV5640)
config.frame_size = FRAMESIZE_QSXGA; // 2560x1920
#else
config.frame_size = FRAMESIZE_UXGA; // 1600x1200 (安全默认值)
#endif
// config.frame_size = FRAMESIZE_SXGA;
config.jpeg_quality = 12;
config.fb_count = 1;
config.fb_location = CAMERA_FB_IN_PSRAM;
}
// —— 摄像头任务 ——
// 摄像头任务函数,循环获取摄像头帧数据,并将其发送到队列中
// 如果获取失败,则延时10毫秒后重试
void cameraTask(void *pvParameters)
{
while (1)
{
camera_fb_t *fb = esp_camera_fb_get();
if (!fb)
{
Serial.println("Camera capture failed");
vTaskDelay(10 / portTICK_PERIOD_MS);
continue;
}
if (xQueueSend(frameQueue, &fb, 0) != pdTRUE)
{
esp_camera_fb_return(fb);
}
vTaskDelay(30 / portTICK_PERIOD_MS);
}
}
void cameraTask_InitCameraSoftwareConfig()
{
sensor = esp_camera_sensor_get();
/* 传感器默认配置 */
sensor->set_contrast(sensor, special2); // 对比度:0 (中间值,范围通常-2~2)
sensor->set_brightness(sensor, special2); // 亮度:0 (中间值,范围通常-2~2)
sensor->set_saturation(sensor, special2); // 饱和度:0 (中间值,范围通常-2~2)
// sensor->set_sharpness(sensor, 1); // 锐度:0 (关闭锐化)
// sensor->set_denoise(sensor, 1); // 降噪:0 (关闭降噪)
// sensor->set_gainceiling(sensor, GAINCEILING_8X); // 增益上限:8倍 (防止过曝)
// sensor->set_quality(sensor, 10); // JPEG质量:10 (0-63,值越低压缩率越高)
// sensor->set_colorbar(sensor, 0); // 彩条测试:0 (关闭测试模式)
// sensor->set_whitebal(sensor, 1); // 自动白平衡:1 (开启)
// sensor->set_gain_ctrl(sensor, 1); // 自动增益控制:1 (开启)
// sensor->set_exposure_ctrl(sensor, 1); // 自动曝光控制:1 (开启)
// sensor->set_hmirror(sensor, 0); // 水平镜像:0 (关闭)
#if defined(OV2640)
sensor->set_vflip(sensor, 0); // 1280x1024
#elif defined(OV5640)
sensor->set_vflip(sensor, 1); // 2560x1920
#else
sensor->set_vflip(sensor, 1); // 1600x1200 (安全默认值)
#endif
// 垂直翻转:0 (关闭)
// sensor->set_aec2(sensor, 1); // AEC2算法:0 (关闭)
// sensor->set_awb_gain(sensor, 1); // AWB增益:1 (开启)
// sensor->set_agc_gain(sensor, 0); // AGC增益值:0 (自动)
// sensor->set_aec_value(sensor, 1200); // 曝光值:600 (1-1200,单位行数)
sensor->set_special_effect(sensor, special); // 特效:0 (无特效)
// sensor->set_wb_mode(sensor, 0); // 白平衡模式:0 (自动)
// sensor->set_ae_level(sensor, 1); // AE补偿:0 (无补偿)
// sensor->set_dcw(sensor, 1); // DCW(下采样):1 (开启)
// sensor->set_bpc(sensor, 1); // 坏点校正:1 (开启)
// sensor->set_wpc(sensor, 1); // 白点校正:1 (开启)
// sensor->set_raw_gma(sensor, 1); // RAW伽马校正:1 (开启)
// sensor->set_lenc(sensor, 1); // 镜头校正:1 (开启)
// 时钟频率:20MHz (典型工作频率)
}
// —— 摄像头初始化 ——
// 初始化摄像头,配置相机参数,并创建帧队列
void cameraTask_Init()
{
cameraTask_InitCameraConfig();
if (esp_camera_init(&config) != ESP_OK)
{
Serial.println("Camera init failed!");
while (1)
{
Serial.println("Retrying camera init...");
// 如果初始化失败,等待100毫秒后重试
displayTask_ErrorLOG("Not Found Camera.\n\nPlease check the camera connection and reboot.");
esp_camera_deinit();
cameraTask_InitCameraConfig();
if (esp_camera_init(&config) == ESP_OK)
{
tft.fillScreen(TFT_BLACK);
break; // 成功则跳出循环
}
}
delay(1000);
}
Serial.println("Camera init ok!");
// 创建帧队列
frameQueue = xQueueCreate(1, sizeof(camera_fb_t *));
if (!frameQueue)
{
Serial.println("Failed to create frame queue");
while (1)
;
}
Serial.println("create frame queue ok!");
cameraTask_InitCameraSoftwareConfig();
// sensor->set_hmirror(sensor, 1); // 设置水平翻转
}
在显示任务程序中这里主要关注下面这几个函数:
其中void displayTask_Init()是显示任务,这里的显示逻辑和按键逻辑在一起,如果你打算修改按键功能,可以在这里修改。
displaycamera()这里则是相机预览窗的实现源码,这里将存在队列里的帧数据取出来,然后推到sprite屏幕显示缓冲区,然后我们在缓冲区上绘制好9宫格,FPS、图标等信息,绘制完成后再统一推送到前端显示,这样就避免了重复绘制显示区导致屏幕闪烁的问题。
displayTask_PhotoSave()这里就是拍照的实现代码,在这个里面,我们先延迟100ms,这100ms是确保屏幕显示任务能正常显示保存窗口,随后先暂停摄像头任务并删除掉,然后清除配置再重新加载jpeg的配置拍照获取帧,并存储到tf卡,存储完成后再切回之前RGB565配置并重启预览仍任务。这样刚好在执行displayTask_PhotoSave()拍摄状态下displaycamera()会不工作,使其画面卡住在之前获取的帧队列的某一帧中,让用户感觉这是拍摄后的照片预览,直到一切任务结束重启CameraTask时预览窗口displaycamera()才会继续工作。
void displayTask_Gallery(int index)这里则是相册的实现代码,这里我添加了一个宏定义,用于区分OV2640和OV5640,5640的拍照分辨率较大,所以显示这里缩小8倍,2640则是缩小4倍,你可以在config.h激活宏定义,由于5640分辨率大,JPGE转RGB565所需时间更长,所以这里我们先让屏幕黑屏,然后在最底下输出Photo loading...来模拟加载中,当使用TJpgDec.drawSdJpg(0, 0, filename);时,图片会水平方向依次显示出来,直到完全覆盖Photo loading...,最后再添加图标和其他信息覆盖屏幕上的像素
在屏幕显示头文件中,定义了屏幕的引脚IO,如果你的屏幕线序和我们提供的电路板焊盘线序不符,你可以在这里修改引脚定义,这里的定义主要是用于初始化IO。在这里修改好后,还应该在.pio/libdeps/esp32-s3-devkitc-1/TFT_eSPI/User_Setup.h内修改引脚定义。
/**
* @file displayTask.cpp
* @brief 显示任务管理
* @details 该文件包含了显示相关功能的声明
* @version 1.0
* @date 2025-6-30
*/
#include "displayTask.h"
#include "cameraTask.h"
#include "Arduino.h"
#include "image.cpp"
#include "tfCard.h"
#include "keyTask.h"
#include "config.h"
#include
#pragma once
bool showSavingPopup = false;
SPIClass SPI_LCD(HSPI);
TFT_eSPI tft;
TFT_eSprite sprite = TFT_eSprite(&tft); // 双缓冲 Sprite
volatile int currentPhotoIndex = -1;
volatile bool photoViewMode = false;
static unsigned long lastMillis = 0;
static int frames = 0;
float fps = 0;
camera_fb_t *fb = NULL;
extern TaskHandle_t cameraTaskHandle;
extern SemaphoreHandle_t camMutex;
void displayTask_PhotoSave()
{
showSavingPopup = true;
vTaskDelay(100 / portTICK_PERIOD_MS); // 给予一定延时,确保屏幕能显示保存提示
// 1. 暂停 cameraTask
if (cameraTaskHandle != NULL)
{
vTaskDelete(cameraTaskHandle);
cameraTaskHandle = NULL;
Serial.println("Camera task deleted");
// 等待 DMA 或相机控制器完全释放(非常关键)
vTaskDelay(30 / portTICK_PERIOD_MS);
}
esp_camera_deinit();
// vTaskDelay(100 / portTICK_PERIOD_MS); // 再次确保硬件资源释放
cameraTask_InitPicConfig();
esp_err_t err = esp_camera_init(&config);
cameraTask_InitCameraSoftwareConfig();
if (err != ESP_OK)
{
Serial.printf("Camera reinit JPEG failed: %d\n", err);
return;
}
// 5. 拍照
camera_fb_t *fb = esp_camera_fb_get();
tfCard_SDWriteFile(fb->buf, fb->len);
;
esp_camera_fb_return(fb);
// 6. 切回预览模式
esp_camera_deinit();
vTaskDelay(100 / portTICK_PERIOD_MS);
cameraTask_InitCameraConfig();
esp_camera_init(&config);
cameraTask_InitCameraSoftwareConfig();
// 重建 cameraTask
// keyTask_LEDFlash(false);
showSavingPopup = false;
xTaskCreatePinnedToCore(cameraTask, "CameraTask", 4096, NULL, 1, &cameraTaskHandle, 0);
Serial.println("Camera task restarted");
}
// Tjpg_Decoder回调函数
bool tft_output(int16_t x, int16_t y, uint16_t w, uint16_t h, uint16_t *bitmap)
{
if (y >= tft.height())
return 0;
tft.pushImage(x, y, w, h, bitmap);
return 1;
}
void displayTask_Gallery(int index)
{
#if defined(OV2640)
TJpgDec.setJpgScale(4);
#elif defined(OV5640)
TJpgDec.setJpgScale(8);
#else
TJpgDec.setJpgScale(8);
#endif
TJpgDec.setCallback(tft_output);
tft.setSwapBytes(true); // We need to swap the colour bytes (endianess)
char filename[32];
sprintf(filename, "/photo_%d.jpg", index);
tft.fillScreen(TFT_BLACK);
tft.setCursor(5, 220);
tft.setTextSize(2);
tft.setTextColor(TFT_YELLOW);
tft.printf("Photo loading...\n");
TJpgDec.drawSdJpg(0, 0, filename);
tft.setCursor(5, 0);
tft.setTextColor(TFT_WHITE);
tft.setTextSize(2);
uint16_t w = 0, h = 0;
TJpgDec.getSdJpgSize(&w, &h, filename);
tft.printf(filename);
tft.setCursor(5, 220);
tft.setTextSize(2);
tft.setTextColor(TFT_YELLOW);
tft.printf("DPI:%dx%d\n", w, h);
tft.pushImage(290, 105, 30, 30, camera);
tft.pushImage(290, 5, 30, 30, up);
tft.pushImage(290, 205, 30, 30, down);
Serial.printf("Showing: %s\n", filename);
}
// 屏幕初始化
void displayTask_Init()
{
SPI_LCD.begin(BOARD_LCD_SCK, BOARD_LCD_MOSI, BOARD_LCD_CS);
tft.begin();
tft.setRotation(1);
pinMode(BOARD_LCD_BL, OUTPUT);
digitalWrite(BOARD_LCD_BL, HIGH);
tft.fillScreen(TFT_BLACK);
tft.pushImage(0, 0, 320, 240, logo);
delay(5000);
tft.fillScreen(TFT_BLACK);
TJpgDec.setJpgScale(8);
TJpgDec.setCallback(tft_output);
tft.setSwapBytes(true); // We need to swap the colour bytes (endianess)
}
// 绘制 3x3 网格
void displayTask_DrawGrid3x3(uint16_t *img, int w, int h, uint16_t color)
{
int cellW = w / 3, cellH = h / 3;
for (int y = 0; y < h; y++)
{
img[y * w + cellW] = color;
img[y * w + 2 * cellW] = color;
}
for (int x = 0; x < w; x++)
{
img[cellH * w + x] = color;
img[2 * cellH * w + x] = color;
}
}
//
void displayTask_ErrorLOG(const char *message)
{
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_RED);
tft.setTextSize(2);
tft.setCursor(5, 170);
tft.println(message);
tft.pushImage(100, 10, 128, 128, tfcard);
while (1)
{
delay(1000); // 停止在错误状态
}
}
// 显示相机画面
void displaycamera()
{
if (xQueueReceive(frameQueue, &fb, portMAX_DELAY) == pdTRUE)
{
frames++;
unsigned long now = millis();
if (now - lastMillis >= 1000)
{
fps = frames * 1000.0f / (now - lastMillis);
frames = 0;
lastMillis = now;
}
uint16_t *img = (uint16_t *)fb->buf;
int w = fb->width;
int h = fb->height;
sprite.createSprite(w, h);
sprite.setSwapBytes(false);
sprite.pushImage(0, 0, w, h, img);
// 网格绘制
displayTask_DrawGrid3x3((uint16_t *)sprite.getPointer(), w, h, TFT_WHITE);
// 显示 FPS
sprite.setTextColor(TFT_WHITE);
sprite.setTextSize(2);
char infoStr3[32];
sprintf(infoStr3, "FPS: %d", (int)fps);
sprite.drawString(infoStr3, 5, 200);
// 显示固定信息
sprite.setTextColor(TFT_YELLOW);
sprintf(infoStr3, "Mode:%d", special);
sprite.drawString(infoStr3, 210, 15);
// 显示固定信息
sprite.pushImage(290, 105, 30, 30, photo);
sprite.pushImage(290, 5, 30, 30, color);
sprite.pushImage(0, 0, 150, 25, minilogo);
sprite.pushImage(290, 205, 30, 30, sun);
// 显示 DPI 信息
sprite.setTextColor(TFT_YELLOW);
sprintf(infoStr3, "light:%d", special2);
sprite.drawString(infoStr3, 195, 220);
sprintf(infoStr3, "DPI: %dx%d", w, h);
sprite.drawString(infoStr3, 5, 220);
// 弹窗提示
if (showSavingPopup)
{
sprite.setTextColor(TFT_YELLOW, TFT_BLACK);
sprite.drawString(" S A V E ... ", 90, 100);
}
sprite.pushSprite(0, 0);
sprite.deleteSprite();
esp_camera_fb_return(fb);
}
}
// 主显示任务(预览/查看照片模式切换)
void displayTask(void *pvParameters)
{
static bool executed = false;
while (1)
{
if (btMIDstate == 0)
{
tft.setSwapBytes(false);
executed = false;
photoViewMode = false;
displaycamera(); // 预览模式
if (btTOPstate == 1)
{
btTOPstate = 0;
if (special > 5)
{
special = 0;
}
else
{
special++;
}
cameraTask_InitCameraSoftwareConfig();
}
if (btDownstate == 1)
{
btDownstate = 0;
if (special2 > 1)
{
special2 = -2;
}
else
{
special2++;
}
cameraTask_InitCameraSoftwareConfig();
}
}
else
{
if (!executed)
{
// 第一次进入相册
int lastIndex = tfCard_GetNextPhotoIndex() - 1;
if (lastIndex >= 1)
{
currentPhotoIndex = lastIndex;
displayTask_Gallery(currentPhotoIndex);
photoViewMode = true;
}
else
{
displayTask_ErrorLOG(" No photos found .\n\n Please take photos first .");
}
executed = true;
}
// 在照片浏览模式中响应 上下键
if (photoViewMode)
{
if (btTOPstate == 1)
{
btTOPstate = 0;
if (currentPhotoIndex > 1)
{
currentPhotoIndex--;
displayTask_Gallery(currentPhotoIndex);
}
}
if (btDownstate == 1)
{
btDownstate = 0;
if (currentPhotoIndex < tfCard_GetNextPhotoIndex() - 1)
{
currentPhotoIndex++;
displayTask_Gallery(currentPhotoIndex);
}
}
}
}
vTaskDelay(10 / portTICK_PERIOD_MS); // 控制刷新速率
}
}
在头文件中,我们定义了按键的IO引脚以及按键的状态,在cpp实现中我们通过状态机来检查按键短按、长按、双击操作。比如长按拍摄键启用闪光灯等等,你可以在对应的cpp文件中丰富这些按键功能。
/**
* @file keyTask.h
* @brief 按键任务管理
* @details 该文件包含了按键相关功能的声明
* @version 1.0
* @date 2025-6-30
*/
#include "keyTask.h"
#define DOUBLE_CLICK_THRESHOLD 400 // 双击最大间隔
#define LONG_PRESS_THRESHOLD 800 // 长按判断阈值
#define DEBOUNCE_DELAY 100 // 去抖动时间
enum KeyState
{
IDLE,
PRESSED,
WAIT_SECOND_PRESS,
LONG_PRESSED
};
struct KeyInfo
{
int pin;
volatile bool flag;
KeyState state;
unsigned long pressStart;
unsigned long lastPress;
bool skipNextSingle; // 用于跳过单击事件
};
KeyInfo keys[4] = {
{KEY_CAM_NUM, false, IDLE, 0, 0, true}, // 拍照
{KEY_TOP_NUM, false, IDLE, 0, 0, true}, // 上键
{KEY_MID_NUM, false, IDLE, 0, 0, true}, // 中键
{KEY_DOWN_NUM, false, IDLE, 0, 0, true} // 下键
};
// 用于主程序判断按键逻辑
int btMIDstate = 0;
int btTOPstate = 0;
int btDownstate = 0;
// 中断回调
void IRAM_ATTR onKeyCamPress() { keys[0].flag = true; }
void IRAM_ATTR onKeyTopPress() { keys[1].flag = true; }
void IRAM_ATTR onKeyMidPress() { keys[2].flag = true; }
void IRAM_ATTR onKeyDownPress() { keys[3].flag = true; }
void keyTask_Init()
{
for (int i = 0; i < 4; i++)
{
pinMode(keys[i].pin, INPUT_PULLUP);
}
pinMode(LED_FLASH_NUM, OUTPUT);
digitalWrite(LED_FLASH_NUM, HIGH);
attachInterrupt(digitalPinToInterrupt(KEY_CAM_NUM), onKeyCamPress, FALLING);
attachInterrupt(digitalPinToInterrupt(KEY_TOP_NUM), onKeyTopPress, FALLING);
attachInterrupt(digitalPinToInterrupt(KEY_MID_NUM), onKeyMidPress, FALLING);
attachInterrupt(digitalPinToInterrupt(KEY_DOWN_NUM), onKeyDownPress, FALLING);
}
void handleKey(KeyInfo &key, const char *name, int &stateVar, void (*singleClick)(), void (*doubleClick)(), void (*longPress)())
{
unsigned long now = millis();
if (key.flag)
{
key.flag = false;
if (key.state == IDLE)
{
key.pressStart = now;
key.state = PRESSED;
key.skipNextSingle = false; // 重置标志
}
else if (key.state == WAIT_SECOND_PRESS && (now - key.lastPress) < DOUBLE_CLICK_THRESHOLD)
{
if (doubleClick)
doubleClick();
Serial.printf("%s 双击\n", name);
key.state = IDLE;
key.skipNextSingle = true;
}
}
if (key.state == PRESSED && (now - key.pressStart > LONG_PRESS_THRESHOLD))
{
if (longPress)
longPress();
Serial.printf("%s 长按\n", name);
key.state = LONG_PRESSED;
key.skipNextSingle = true;
}
if ((key.state == PRESSED || key.state == LONG_PRESSED) && digitalRead(key.pin) == HIGH)
{
if (key.state == PRESSED)
{
key.lastPress = now;
key.state = WAIT_SECOND_PRESS;
}
else
{
key.state = IDLE; // 长按后松开
}
}
if (key.state == WAIT_SECOND_PRESS && (now - key.lastPress > DOUBLE_CLICK_THRESHOLD))
{
if (!key.skipNextSingle && singleClick)
{
singleClick();
Serial.printf("%s 单击\n", name);
}
key.state = IDLE;
}
}
// 回调函数示例(你可按需修改)
void camSingleClick() { displayTask_PhotoSave(); }
void camDoubleClick()
{
keyTask_LEDFlash(true);
displayTask_PhotoSave();
keyTask_LEDFlash(false);
}
void camLongPress()
{
keyTask_LEDFlash(true);
displayTask_PhotoSave();
keyTask_LEDFlash(false);
}
void topSingleClick() { btTOPstate = 1; }
void topDoubleClick() { Serial.println("上键双击触发"); }
void topLongPress() { Serial.println("上键长按触发"); }
void midSingleClick() { btMIDstate = !btMIDstate; }
void midDoubleClick() { Serial.println("中键双击触发"); }
void midLongPress() { Serial.println("中键长按触发"); }
void downSingleClick() { btDownstate = 1; }
void downDoubleClick() { Serial.println("下键双击触发"); }
void downLongPress() { Serial.println("下键长按触发"); }
void keyTask(void *pvParameters)
{
while (1)
{
handleKey(keys[0], "拍照键", btMIDstate, camSingleClick, camDoubleClick, camLongPress);
handleKey(keys[1], "上键", btTOPstate, topSingleClick, topDoubleClick, topLongPress);
handleKey(keys[2], "中键", btMIDstate, midSingleClick, midDoubleClick, midLongPress);
handleKey(keys[3], "下键", btDownstate, downSingleClick, downDoubleClick, downLongPress);
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void keyTask_LEDFlash(bool on)
{
// 控制LED闪烁
if (on)
{
digitalWrite(LED_FLASH_NUM, LOW); // LED亮
}
else
{
digitalWrite(LED_FLASH_NUM, HIGH); // LED灭
}
}
页面这里其实没啥好讲的,和我前面TV-Pro、TV-Lite、EDA-Robot项目的页面内容都是一样的,你可以理解为ESP通过热点提供的内网开启了一个服务器,所以只要你和ESP在同一网络下就可以访问到ESP服务器内的网页资源。
webTask_listFiles()在之前的项目中网页都是以html的形式存储到FS文件系统中去然后再到主程序通过解析html的形式打开,在这个项目中则是直接把网页写入到程序中。
例如:
while (true)
{
File entry = root.openNextFile();
if (!entry)
break;
String name = entry.name();
if (!entry.isDirectory() && (name.endsWith(".jpg") || name.endsWith(".png")))
{
html += "<li>";
html += "<p>文件名:" + name + "</p>";
html += "<a href="\">📷 预览</a>";
html += "<a href="\">⬇ 下载</a>";
html += "<a href="\">❌ 删除</a>";
html += "</li>";
}
entry.close();
}
html += "";
这样的形式可以直接在HTML内写C语言读取文件,动态拼接字符串。webTask_Init()则和之前的项目代码一样,配置好页面路由,然后再到void webTask(void *pvParameters)配置好任务,一切完成后把任务丢到main.cpp就不用管了。
页面任务中我们通过WIFI热点实现webserver局域网站,连接热点后访问192.168.4.1进入后台即可访问TF卡的照片,你可以在这个的头文件中自定义热点的WIFI_Name、WIFI_Password。
/**
* @file webTask.cpp
* @brief Web任务管理
* @details 该文件实现了Web相关功能,包括文件浏览、下载、预览和删除
* @version 1.0
* @date 2025-6-30
*/
#include "webTask.h"
#include
#include
#include
#include
const char *ap_ssid = WIFI_Name;
const char *ap_password = WIFI_Password;
WebServer server(80);
void webTask_listFiles()
{
File root = SD.open("/");
String html = R"rawliteral(
图片浏览器
<h2>📂 TF卡图片浏览器</h2>
<ul>
)rawliteral";
while (true)
{
File entry = root.openNextFile();
if (!entry)
break;
String name = entry.name();
if (!entry.isDirectory() && (name.endsWith(".jpg") || name.endsWith(".png")))
{
html += "<li>";
html += "<p>文件名:" + name + "</p>";
html += "<a href="\">📷 预览</a>";
html += "<a href="\">⬇ 下载</a>";
html += "<a href="\">❌ 删除</a>";
html += "</li>";
}
entry.close();
}
html += "</ul>";
server.send(200, "text/html", html);
}
void webTask_HandleDownload()
{
if (!server.hasArg("file"))
{
server.send(400, "text/plain", "缺少 file 参数");
return;
}
String filename = server.arg("file");
File file = SD.open("/" + filename);
if (!file)
{
server.send(404, "text/plain", "文件未找到");
return;
}
String contentType = "application/octet-stream";
server.sendHeader("Content-Type", contentType);
server.sendHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
server.sendHeader("Connection", "close");
server.streamFile(file, contentType);
file.close();
}
void webTask_HandleView()
{
if (!server.hasArg("file"))
{
server.send(400, "text/plain", "缺少 file 参数");
return;
}
String filename = server.arg("file");
File file = SD.open("/" + filename);
if (!file)
{
server.send(404, "text/plain", "图片未找到");
return;
}
String contentType = filename.endsWith(".jpg") ? "image/jpeg" : "image/png";
server.streamFile(file, contentType);
file.close();
}
void webTask_HandleDelete()
{
if (!server.hasArg("file"))
{
server.send(400, "text/plain", "缺少 file 参数");
return;
}
String filename = server.arg("file");
if (SD.exists("/" + filename))
{
SD.remove("/" + filename);
server.sendHeader("Location", "/"); // 设置重定向目标
server.send(302, "text/plain", "Redirecting to home..."); // 发送重定向响应
}
else
{
server.send(404, "text/plain", "文件未找到");
}
}
void webTask_Init()
{
// 启动自建WiFi热点
WiFi.softAP(ap_ssid, ap_password);
IPAddress IP = WiFi.softAPIP();
Serial.print("AP IP地址: ");
Serial.println(IP); // 默认是 192.168.4.1
// 路由
server.on("/", HTTP_GET, webTask_listFiles);
server.on("/view", HTTP_GET, webTask_HandleView);
server.on("/download", HTTP_GET, webTask_HandleDownload);
server.on("/delete", HTTP_GET, webTask_HandleDelete);
// server.on("/cam", HTTP_GET, displayTask_PhotoSave);
server.begin();
Serial.println("Web服务器已启动");
}
void webTask(void *pvParameters)
{
while (1)
{
server.handleClient();
vTaskDelay(30 / portTICK_PERIOD_MS);
}
}
这个配置文件是硬件配置文件,这里我已经给好了OV5640和OV2640的定义,按照你的CMOS模块型号修改注释即可,使用OV5640则会开启2.5K 500万像素,如果是OV2640则是1.3K 200万像素。项目中我们使用的屏幕是ST7789驱动,如果你使用的是ILI9341则需要去.pio/libdeps/esp32-s3-devkitc-1/TFT_eSPI/User_Setup.h修改注释
/**-------------摄像头配置----------------
* 根据你的CMOS型号配置定义
* OV2640:#define OV2640
* OV5640:#define OV5640
*/
#define OV5640
//#define OV2640
/**-------------屏幕驱动配置----------------
* 根据你的屏幕型号配置定义,配置文件路径如下(如果下面路径不存在请先编译拉取依赖库)
* .pio/libdeps/esp32-s3-devkitc-1/TFT_eSPI/User_Setup.h
* ST7789:#define ST7789_DRIVER
* ILI9341:#define ILI9341_DRIVER
*/
在本项目中为了画面实时性流畅性避免丢帧,我把CameraTask任务挂到核心0上单独运行并设置最高优先级。而其他任务则都挂到核心1上,然后配置好优先级,按键优先级最高,因为要实时响应,其次是屏幕,最后是网络相册。
/**
* @file main.cpp
* @brief 主程序文件
* @details 主要功能实现
* @version 1.0
* @date 2025-6-30
*/
#include "cameraTask.h"
#include "displayTask.h"
#include "tfCard.h"
#include "webTask.h"
#include
#include
#include
#include "keyTask.h"
SemaphoreHandle_t camMutex;
TaskHandle_t cameraTaskHandle = NULL; // 声明全局句柄变量
void setup()
{
Serial.begin(115200);
// 按键及闪光灯初始化
// keyTask_Init();
camMutex = xSemaphoreCreateMutex();
displayTask_Init();
keyTask_Init();
// 初始化TF卡
tfCard_Init();
// 初始化屏幕
// 初始化摄像头
cameraTask_InitCameraConfig();
// 初始化相册配置
cameraTask_Init();
// 初始化网络文件管理器
webTask_Init();
// 启动摄像头任务
xTaskCreatePinnedToCore(cameraTask, "CameraTask", 4096, NULL, 3, &cameraTaskHandle, 0);
// 启动预览任务
xTaskCreatePinnedToCore(displayTask, "DisplayTask", 4096, NULL, 2, NULL, 1);
// 启动网络相册任务
xTaskCreatePinnedToCore(webTask, "webTask", 4096, NULL, 1, NULL, 1);
// 启动按键任务
xTaskCreatePinnedToCore(keyTask, "keyTask", 4096, NULL, 3, NULL, 1);
}
void loop()
{
}
3D外壳结构

- 内壁设置有限位槽,为按键预留空间。
- 按键部分全部减薄,因为拍摄键那个按键比较厚,不挖掉的话屏幕就凹进去了。
- 主体外壳采用大圆角,使得整体美观圆润。

-
后壳同样采用大圆角,使得整机圆润。
-
内边框采用倒角加强。
-
其实原本是想做成单反那种,把拍摄键放到前面,但是那样的话按键要用FPC或者飞线,那就很抽象了。
-
如果你想做成卡片相机那种,可以用单节电池,重新设计下外壳。
安装结构
项目采用的是双外壳结构,由前盖、后盖构成

| 1 | 2 |
|---|---|
![]() | ![]() |
![]() | ![]() |
![]() | ![]() |
| 预览窗界面 | 相册界面 |
![]() | ![]() |
| OV2640广角模块 | OV5640标准模块 |
![]() | ![]() |
感谢 @嘉立创EDA活动酱 提供的实物拍摄图
- 专业相机模式,通过UI调节曝光、白平衡等等
- 触控功能,改用电容触控屏,触屏交互体验
- 网络打印功能,通过蓝牙或WIFI发送文件到打印机打印
- AI识别功能,检测到人脸时自动拍照
- 翻转屏结构,优化外壳结构,实现屏幕翻转
- ......
1.购买屏幕时请务必注意线序,确保线序与PCB焊盘线序一致。(屏幕驱动ST7789,使用ILI9341也可以软件改,驱动无所谓,7789价格相对9341较低)
2.购买CMOS模组时务注意线序,确保线序与PCB接口线序一致。(OV2640/OV5640均可驱动,软件修改即可,2640价格底但成像差,5640价格高但发热量大)
3.如果屏幕背部是钢壳,则焊接时注意与PCB板绝缘(可以垫纸或贴胶带),避免插件焊盘接触钢壳引起短路。
4.TF存储卡仅支持FAT16文件系统,SDSC模式,最大2GB
5.设置为1A充电电流时建议为充电芯片添加散热片,使用OV5640时建议为OV5640模组背面及LDO也添加散热片。
编译后产物烧录
如果你通过源码编译,则会在build目录下产生bootloader.bin partitions.bin firmware.bin这三个bin文件,所以在烧录时请按照下图提供的分区地址烧录。
| 产物名 | 烧录地址 |
|---|---|
| bootloader.bin | 0x00000000 |
| partitions.bin | 0x00008000 |
| boot_app0.bin | 0xe000 |
| firmware.bin | 0x00010000 |

PCB v1.2
1.修正重叠的丝印。
外壳 v2.0
前壳
1.前壳新增屏幕隔离层,避免接触PCB短路。

后壳
1.优化设计,符合打印要求
2.新增支持CS接口版本,支持更换镜头
(需购买CS镜头底座,20mm孔距)

面板
1.新增面板设计

设计图
未生成预览图,请在编辑器重新保存一次BOM
暂无BOM
克隆工程知识产权声明&复刻说明
本项目为开源硬件项目,其相关的知识产权归创作者所有。创作者在本平台上传该硬件项目仅供平台用户用于学习交流及研究,不包括任何商业性使用,请勿用于商业售卖或其他盈利性的用途;如您认为本项目涉嫌侵犯了您的相关权益,请点击上方“侵权投诉”按钮,我们将按照嘉立创《侵权投诉与申诉规则》进行处理。
请在进行项目复刻时自行验证电路的可行性,并自行辨别该项目是否对您适用。您对复刻项目的任何后果负责,无论何种情况,本平台将不对您在复刻项目时,遇到的任何因开源项目电路设计问题所导致的直接、间接等损害负责。





























评论