# EDA-Camera照相机 文档:https://wiki.yang17.top/products/oshwhub/oshw-6e8db410b8ab4bbe8e5bc259859b4304/ 项目来源:https://oshwhub.com/course-examples/camera 分类:硬件项目 更新时间:2026-08-08T07:05:58.000Z 基于立创开发板ESP32S3R8N8构建,支持500万像素的卡片照相机,支持局域网相册管理,可自由选择OV2640/OV5640,贴片阻容采用0805封装,适合初学者焊接。 资料超详细 👇 项目介绍 硬件设计 软件设计 3D结构 附件下载 硬件工程 项目简介🪄 项目介绍 本项目基于搭载双核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卡时提示 项目参数🔮 采用【立创开发板 ESP32S3R8N8】作为主控,搭载 Xtensa® 32 位 LX7 双核RISC处理器,主频高达 240 MHz 摄像头CMOS支持200万像素的OV2640和支持500万像素的OV5640模块,模块化设计,可自由更换 屏幕采用ST7789驱动的2.4寸屏幕,240*320分辨率 电池采用两节并联14500锂电池,满电电压4.2V 充电方案采用TP4056芯片,最大1A充电电流 硬件设计 主控电路 主控选用立创开发板 ESP32S3R8N8,搭载 Xtensa® 32 位 LX7 双核RISC架构处理器,主频高达 240 MHz,板载8MB PSRAM 8MB FLASH ,集成WIFI/蓝牙,提供了强大的性能及资源。 查看数据手册 开发板源工程 前往立创商城 屏幕电路 屏幕这里我们选用的是2.4寸240*320LCD屏,该尺寸的屏幕有两种驱动芯片,本项目使用的是ST7789驱动芯片,当然你也可以用ILI9341驱动芯片,需要修改TFT-espid的驱动定义即可。这里推荐购买IPS面板的屏幕,IPS面板相对于TFT面板能获得更好的视觉体验。 (选用屏幕时切记确保屏幕线序与PCB焊盘线序匹配) ST7789屏幕模组模组 原理图 (本项目未添加触控功能,无需购买带触控面板的屏幕,当然你可以自己拓展触控功能) 屏幕信息 引脚定义 CMOS模块 本项目中共测试了OV5640及OV2640 CMOS模块,两款模块均可支持,软件源码中提供定义,按型号选用即可。 (选用模块时切记确保模块线序与PCB接口线序匹配) OV2640/OV5640模组 原理图 CMOS模组内LDO前级有滤波电容,所以这里原理图模组供电端就不用加滤波电容了 查看数据手册 TF卡座 这里我们选用了弹出式TF卡座,选用SPI协议通讯。 (SDSC模式,FAT16文件系统,最大支持2GB TF卡) 查看数据手册 前往立创商城 电池供电电路 电池供电电路中选用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 内核版本: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而解析导致不显示,所以完整的代码块内容建议到附件中查看源码文件) cameraTask.cpp 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); // 设置水平翻转 } displayTask 屏幕显示任务 在显示任务程序中这里主要关注下面这几个函数: 其中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); // 控制刷新速率 } } keyTask 按键任务 按键这里为了能让仅有的四个按键能实现更多功能,这里我使用了状态机处理,通过状态机可以知道按键是长按、短按还是双击,在实际程序中我只添加了长按拍摄时开启补光灯,其他状态的功能没有做拓展,你可以根据这里的状态机添加按键拓展功能。这里的三个按键都添加了中断,这样可以确保按键按下后能立即执行对应功能,避免等待或时间片未到时触发导致延迟响应或无响应等问题 在头文件中,我们定义了按键的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灭 } } webTask 页面任务 页面这里其实没啥好讲的,和我前面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 += "
文件名:" + name + "
"; html += "📷 预览"; html += "⬇ 下载"; html += "❌ 删除"; html += "文件名:" + name + "
"; html += "📷 预览"; html += "⬇ 下载"; html += "❌ 删除"; html += "