外观
软件开发
开发环境
- 软件环境:VSCode+PlatformIO
- 开发语言:C/C++
依赖库
通过以下开源库协助本项目开发 - TFT_eSPI:用于屏幕显示驱动
- TJpg_Decoder:用于将JPEG格式转换为RGB565
FreeRTOS
本项目中采用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
注释语法,方便管理及生成对应的开发文档。在编辑器中也可通过光标移动显示函数功能,用途等。
2.1 环境搭建
本项目的构建环境为VsCode、PlatformIO完成,当然也可以使用JetBrains CLion 2024、PlatfromIO或Arduino完成,根据个人喜好选择即可。在本教程中主要使用VsCode作为编辑器开发
2.1.1 VsCode安装
方法1:官网下载
VsCode是一个强大的主流代码编辑器,也是完全开源免费使用的,我们可以在VsCode官网下载安装即可 https://code.visualstudio.com/insiders/
方法2:应用商店下载
这种方法更为简便,您可以使用Windows自带微软商店搜索并下载
2.1.2 PlatfromIO框架安装
PlatfromIO是一个更强大更专业的开发框架,支持ARM、FPGA、RISC-V以及8位单片机的开发,是一个嵌入式多平台开发工具,可以支持Arduino、ESPIDF、STM32Cube等多个固件的开发,且自定义程度较高、支持开源库的快速安装使用,非常方便。 在VsCode点击左侧插件图标,搜索安装PlatfromIO框架插件即可,也可直接用ArduinoIDE完成开发,但ArduinoIDE功能有限,修改库函数和板级配置困难,所以不推荐使用
2.1.3 程序下载说明
立创ESP32S3R8N8 开发板集成有 CH340K 串口芯片 及 CH334F USB-HUB芯片,所以不需要再单独购买烧录工具,且ESP32S3系列支持USB烧录,所以你可以通过电脑连接USB自由选择串口烧录模式还是USB烧录模式。
(1)安装CH340驱动
使用串口模式烧录或访问串口时请按此步骤驱动
鼠标右键以管理员身份进行安装; 如果出现安装失败,那可能是你电脑已经有了该驱动,你可以尝试先点击卸载,再次安装即可。
2.2 流程设计
在开发之前,我们先要对整个程序的流程进行设计,确保知道我们的项目需要做什么
2.2.1 整体程序分析
对于本项目中,我们选择将摄像头完全交给核心0运行,确保我们捕捉到的摄像头画面数据是连贯流畅的。而屏幕、按键和局域网文件管理器等较轻或不常用的任务则交给核心1运行,按键则是注册了中断。整体简化后的软件流程图如下:
当然,这仅局限于本教程,你也可以为你的照相机开发更多好玩有意思的功能
2.3 创建项目
2.3.1 构建项目框架
(1)创建项目
首先,我们新建一个PlatformIO项目,修改好项目名称,版型选择Espressif中的ESP32-S3-DevKitC-1-N8,使用Arduino固件然后点击创建。(版型的选择是随意的,主要是选对正确的芯片类型,因为ESP32S3有多种款式,我们使用的类型是ESP32,8M)
(2)配置SDK
默认的platform.ini配置文件是没有启用PSRAM的,本项目中为了能让摄像头画面流畅,屏幕显示流畅均使用了双缓冲,仅靠核心自带的那点512K的SRAM是完全不够用的,所以我们需要启用8MB的PSRAM。
[env:esp32-s3-devkitc-1]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
; 启用内置 PSRAM
board_build.psram = true
; 指定PSRAM的运行模式
board_build.arduino.memory_type = qio_opi
; 预定义宏,启用PSRAM
build_flags = -DBOARD_HAS_PSRAM
1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
现在就已经启用PSRAM了,你可以刷入框架自动生成的示例程序看下是否能正常运行,如果不能则你的开发板可能没有PSRAM或PSRAM损坏,如果没有PSRAM可以通过外接PSRAM拓展。
2.3.2 项目结构介绍
(1)项目文件夹
在项目文件夹下共有5个文件夹,这些文件夹我们在后面介绍。我们先来讲解该目录下最为重要的platformio.ini文件,这个文件是用于存放我们需要调用的第三方库和配置板级配置的文件,在这个文件中可以配置分区、配置处理器频率、固件类型等等
(2).pio文件夹
.pio文件夹是用于存放我们依赖的第三方库文件以及构建后的项目固件及构建文件的地方,默认文件如下 build 用于存放构建文件,生成的固件也会在这个文件夹中 libdeps 当引入第三方库时才会生成该目录,在该目录下可以修改第三方库的源代码
(3)src文件夹
其他文件夹就不再讲解了,主要是用于分类和自行导入第三方库用的。 src文件夹是生成初始程序的文件夹,在新建的项目中会在这里生成main.cpp,这个就是我们的主程序了
2.3.3 程序结构介绍
这就是一个基本的Arduino程序结构,在程序最上面导入头文件和全局变量。 setup() 在这个方法中我们主要存放需要初始化的代码,在该方法中的所有程序仅在开机时执行一次 loop() 在这个方法在我们主要存放主代码,在该方法中的所有程序会进入死循环,无限次重复执行
2.4 驱动库
2.4.1 屏幕
屏幕这里我们选用ST7789 2.4寸,320*240屏幕,使用TFT-eSPI驱动库,这是一个跨平台库,支持多个平台多种核心。
(1)TFT-eSPI驱动库导入
lib_deps =
bodmer/TFT_eSPI @ ^2.5.43
1
2
2
2.4.2 TJpg_Decoder图像格式转换器
TJpg_Decoder可以帮助我们将TF中保存的JPEG格式图片转化为RGB565格式显示到屏幕上,借助这个转换库实现本地相册预览功能
(1)TJpg_Decoder库导入
lib_deps =
bodmer/TJpg_Decoder @ ^1.1.0
1
2
2
然后我们点击左侧PlatformIO的图标,选择Dependencies,点击Update更新依赖
现在platformio会自动拉取第三方库的git仓库到项目
2.5 程序开发
现在,你已经完成了所有准备工作,开始编写程序吧。 为方便理解,本项目教程的程序并没有进行性能和安全优化,如果有需求可以自行修改。
2.5.1 cameraTask 摄像头任务
cameraTask.h
在头文件中,我们定义了摄像头引脚IO,以及为摄像头定义了两个初始化配置,一个是RGB565,一个是JPEG格式,我们的LCD屏幕要实现高帧率原生显示就必须用RGB565格式,而照片的预览格式通常又是JPG格式,所以这里的想法是在预览时使用RGB565,而当按下拍摄键时切换到JPEG格式,拍摄完成后再切回RGB565。
/**
* @file cameraTask.h
* @brief 摄像头任务管理
* @details 该文件包含了摄像头相关功能的声明
* @author JasonYANG170
* @version 1.0
* @date 2025-6-30
*/
#include <TFT_eSPI.h>
#include "esp_camera.h"
#include <freertos/queue.h>
#pragma once
#define PWDN_GPIO_NUM 46
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM -1
#define SIOD_GPIO_NUM 17
#define SIOC_GPIO_NUM 18
#define Y9_GPIO_NUM 21
#define Y8_GPIO_NUM 42
#define Y7_GPIO_NUM 40
#define Y6_GPIO_NUM 41
#define Y5_GPIO_NUM 39
#define Y4_GPIO_NUM 15
#define Y3_GPIO_NUM 38
#define Y2_GPIO_NUM 16
#define VSYNC_GPIO_NUM 48
#define HREF_GPIO_NUM 47
#define PCLK_GPIO_NUM 45
extern TFT_eSPI tft;
extern camera_config_t config;
extern QueueHandle_t frameQueue;
extern sensor_t *sensor;
extern int special;
extern int special2;
/**
* @brief 初始化摄像头配置
* @param 无
* @note 用于初始化摄像头RGB565格式的配置
*/
void cameraTask_InitCameraConfig();
/**
* @brief 初始化摄像头配置
* @param 无
* @note 用于初始化摄像头JPEG格式的配置
*/
void cameraTask_InitPicConfig();
/**
* @brief 摄像头RTOS任务
* @param 无
* @note 主任务处理
*/
void cameraTask(void *pvParameters);
/**
* @brief 初始化摄像头软件
* @param 无
* @note 配置软件功能
*/
void cameraTask_Init();
/**
* @brief 初始化摄像头
* @param 无
* @note 摄像头初始化及帧队列
*/
void cameraTask_InitCameraSoftwareConfig();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
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); // 设置水平翻转
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
2.5.2 displayTask 屏幕显示任务
displayTask.h
在屏幕显示头文件中,定义了屏幕的引脚IO,如果你的屏幕线序和我们提供的电路板焊盘线序不符,你可以在这里修改引脚定义,这里的定义主要是用于初始化IO。在这里修改好后,还应该在.pio/libdeps/esp32-s3-devkitc-1/TFT_eSPI/User_Setup.h
内修改引脚定义。
/**
* @file displayTask.h
* @brief 显示任务管理
* @details 该文件包含了显示相关功能的声明
* @version 1.0
* @date 2025-6-30
*/
#include <Arduino.h>
#include <TFT_eSPI.h>
#pragma once
extern SPIClass SPI_LCD;
extern TFT_eSPI tft;
#define BOARD_LCD_MOSI 2
#define BOARD_LCD_MISO -1
#define BOARD_LCD_SCK 5
#define BOARD_LCD_CS 3
#define BOARD_LCD_DC 4
#define BOARD_LCD_RST -1
#define BOARD_LCD_BL 1
/**
* @brief 屏幕RTOS任务
* @param 无
* @note 屏幕任务处理
*/
void displayTask(void *pvParameters);
/**
* @brief 绘制3X3表格
* @param 无
* @note 绘制拍摄窗口
*/
void displayTask_DrawGrid3x3(uint16_t *img, int w, int h, uint16_t color);
/**
* @brief 绘制保存提示窗
* @param 无
* @note 拍摄后存储提示
*/
void displayTask_DrawSavingPopup(uint16_t *img, int w, int h, const char *msg, uint16_t color);
/**
* @brief 屏幕初始化
* @param 无
* @note 屏幕的初始化内容
*/
void displayTask_Init();
/**
* @brief 屏幕错误弹窗
* @param 无
* @note 出现错误时可通过此函数打印至屏幕
*/
void displayTask_ErrorLOG(const char *message);
extern bool showSavingPopup;
extern bool saveDone;
extern TaskHandle_t DisplayTaskHandle; // 从别的文件访问
extern TaskHandle_t cameraTaskHandle; // 从别的文件访问
/**
* @brief 拍摄函数
* @param 无
* @note 存储拍摄的照片
*/
void displayTask_PhotoSave();
/**
* @brief 相册显示
* @param 无
* @note 显示存储的照片
*/
void displayTask_Gallery(int index);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
displayTask.cpp
在显示任务程序中这里主要关注下面这几个函数:
其中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.h
* @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 <TJpg_Decoder.h>
#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;
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); // 控制刷新速率
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
2.5.3 keyTask 按键任务
keyTask.h
/**
* @file keyTask.h
* @brief 按键任务管理
* @details 该文件包含了按键相关功能的声明
* @version 1.0
* @date 2025-6-30
*/
#include <Arduino.h>
#include "displayTask.h"
#define KEY_CAM_NUM 10
#define KEY_TOP_NUM 9
#define KEY_MID_NUM 8
#define KEY_DOWN_NUM 7
#define LED_FLASH_NUM 6
extern volatile bool keyCamPressed;
extern volatile bool keyTopPressed;
extern volatile bool keyMidPressed;
extern volatile bool keyDownPressed;
extern int btTOPstate;
extern int btMIDstate;
extern int btDownstate;
/**
* @brief 按键任务初始化
* @param 无
* @note 配置按键引脚
*/
void keyTask_Init();
/**
* @brief 补光灯控制函数
* @param bool
* @note 控制补光灯
*/
void keyTask_LEDFlash(bool on);
/**
* @brief 补光灯控制函数
* @param 无
* @note 控制补光灯
*/
void keyTask(void *pvParameters);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
keyTask.cpp
按键这里为了能让仅有的四个按键能实现更多功能,这里我使用了状态机处理,通过状态机可以知道按键是长按、短按还是双击,在实际程序中我只添加了长按拍摄时开启补光灯,其他状态的功能没有做拓展,你可以根据这里的状态机添加按键拓展功能。这里的三个按键都添加了中断,这样可以确保按键按下后能立即执行对应功能,避免等待或时间片未到时触发导致延迟响应或无响应等问题
在头文件中,我们定义了按键的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灭
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
2.5.4 tfCard TF卡操作任务
tfCard.h
/**
* @file tfCard.h
* @brief TF卡管理
* @details 该文件包含了TF卡相关功能的声明
* @version 1.0
* @date 2025-6-30
*/
// SPI对象声明(外部链接)
#include <SD.h>
#include <SPI.h>
extern SPIClass SPI_SD;
// 引脚定义
#define SD_SCK 13
#define SD_MISO 14
#define SD_MOSI 12
#define SD_CS 11
/**
* @brief TF卡初始化
* @param 无
* @note 配置TF卡引脚
*/
void tfCard_Init();
/**
* @brief 写入文件
* @param const uint8_t *data, size_t size
* @note 写入文件至TF卡
*/
void tfCard_SDWriteFile(const uint8_t *data, size_t size);
/**
* @brief 获取下一张照片索引
* @param 无
* @note 用于保存图片时的图片名
*/
int tfCard_GetNextPhotoIndex();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
tfCard.cpp
/**
* @file tfCard.cpp
* @brief TF卡管理
* @details 该文件实现了TF卡的初始化和文件读写功能
* @version 1.0
* @date 2025-6-30
*/
#include "tfCard.h"
#include "esp_camera.h"
#include "displayTask.h"
SPIClass SPI_SD(VSPI);
// 初始化 SD 卡
void tfCard_Init()
{
SPI_SD.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
while (1)
{
if (SD.begin(SD_CS, SPI_SD))
{
Serial.println("SD Card initialized successfully");
break;
}
else
{
Serial.println("SD Card initialization failed, retrying...");
displayTask_ErrorLOG("Not Found TFCard.\n\nPlease check the TFCard connection and reboot.");
delay(1000);
}
tft.fillScreen(TFT_BLACK);
}
}
// 获取下一个未被占用的 photo_编号
int tfCard_GetNextPhotoIndex()
{
int index = 1;
char filename[32];
while (true)
{
sprintf(filename, "/photo_%d.jpg", index);
if (!SD.exists(filename))
{
break;
}
index++;
}
return index;
}
void tfCard_SDWriteFile(const uint8_t *data, size_t size)
{
int photoIndex = tfCard_GetNextPhotoIndex();
char filename[32];
sprintf(filename, "/photo_%d.jpg", photoIndex);
File file = SD.open(filename, FILE_WRITE);
if (file)
{
file.write(data, size);
file.close();
Serial.printf("Saved %s\n", filename);
}
else
{
Serial.println("Save failed");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
2.5.5 webTask 页面任务
webTask.h
/**
* @file webTask.h
* @brief Web任务管理
* @details 该文件包含了Web相关功能的声明
* @version 1.0
* @date 2025-6-30
*/
#define WIFI_Name "ESP32-CAM"
#define WIFI_Password ""
/**
* @brief 获取文件列表
* @param 无
* @note 遍历TF卡内所有文件
*/
void webTask_listFiles();
/**
* @brief 图片下载
* @param 无
* @note 下载选中的图片
*/
void webTask_HandleDownload();
/**
* @brief 图片预览
* @param 无
* @note 预览TF卡中的图片
*/
void webTask_HandleView();
/**
* @brief 初始化网页服务
* @param 无
* @note 用于初始化配置页面服务器
*/
void webTask_Init();
/**
* @brief 获取下一张照片索引
* @param 无
* @note 用于保存图片时的图片名
*/
void webTask_HandleDelete();
/**
* @brief 页面任务
* @param 无
* @note 页面任务处理
*/
void webTask(void *pvParameters);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
webTask.cpp
页面这里其实没啥好讲的,和我前面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="\"/view?file="">📷 预览</a>";
html += "<a href="\"/download?file="">⬇ 下载</a>";
html += "<a href="\"/delete?file="">❌ 删除</a>";
html += "</li>";
}
entry.close();
}
html += "";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这样的形式可以直接在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(
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<title>图片浏览器</title>
<style>
body { font-family: Arial, sans-serif; background-color: #f0f0f0; padding: 20px; }
h2 { color: #333; }
ul { list-style: none; padding: 0; }
li {
background: #fff;
margin: 10px 0;
padding: 10px;
border-radius: 10px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
a {
text-decoration: none;
margin-right: 10px;
font-weight: bold;
color: #007bff;
}
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<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="\"/view?file="">📷 预览</a>";
html += "<a href="\"/download?file="">⬇ 下载</a>";
html += "<a href="\"/delete?file="">❌ 删除</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);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
2.5.6 image 屏幕显示任务
image.cpp
image.cpp存放的是全部用到的RGB565图像素材,如图标和启动动画都在这里面。需要注意的是在相册界面下的图标字节顺序和预览窗下是相反的,因为jpeg转换得到的RGB565是启用了字节交换。
const uint16_t tfcard[] PROGMEM;
const uint16_t sun[] PROGMEM;
const uint16_t minilogo[] PROGMEM;
const uint16_t camera[] PROGMEM;
const uint16_t down[] PROGMEM;
const uint16_t up[] PROGMEM;
const uint16_t color[] PROGMEM;
const uint16_t photo[] PROGMEM;
const uint16_t logo[] PROGMEM;
1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
2.5.7 config 配置文件
config.h
这个配置文件是硬件配置文件,这里我已经给好了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
*/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
2.5.8 main 主进程
main.cpp
由于我们使用的是FreeRTOS操作系统,所以main主进程很简单,初始化后启动任务就好了,就好比你用的手机一样,它可以运行多个APP,每个APP就像一个Task,可以被挂起、切换、恢复。RTOS通过任务调度让多个任务“并发”运行,类似于手机后台运行多个APP。不同的是,RTOS更注重任务的实时性和响应速度。
在本项目中为了画面实时性流畅性避免丢帧,我把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()
{
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
2.6 编译
2.6.1 编译主程序
点击底下一排中的勾,开始构建 如果显示SUCCESS则表示构建成功
2.7 烧录
2.7.1 接线
在立创ESP32S3R8N8开发板中板载了TTL芯片并且支持USB烧录,所有不需要再使用串口烧录器烧录 直接连接TypeC接口到电脑,此时在VsCode左下角应该有端口选择按钮,点击选择或使用默认的自动识别都可以。
4.8.2 烧录主程序
方法1:使用官方程序
构建成功后,在.pio/build/nodemcuv2文件夹下,应该会有bin文件和elf文件,这都是固件,只是类型不同,您可以通过这些固件使用官方烧录器烧录
方法2:通过编译器
点击底部箭头,等待烧录完成即可