2026寒假练 - 基于ESP32‑S3 Sense的智能门禁与报警系统
该项目使用了ESP32‑S3 Sense、cpp语言,实现了智能门禁与报警系统的设计,它的主要功能为:通过超声波检测触发人脸识别,并可以再进行语音口令二次验证,实现开锁控制,同时提供 OLED 状态显示、LED 指示与失败日志记录。
标签
嵌入式系统
MPU
显示
开发板
接口
参加活动
zzy
更新2026-03-24
47

一、所选项目与任务

使用人工智能人工智能硬件实验套件平台设计端侧ai智能门禁与报警系统,要求如下:
1. 训练一个支持人脸比对的轻量化模型,至少应该存储3个用户的数据。人脸图像识别可使用Edge Impulse或其他平台训练相关模型。
2. 训练一个语音识别模型,基于口令关键词来检测,作为智能门禁的第二验证方式。语音识别可使用Edge Impulse或其他平台训练相关模型。
3. 超声波传感器检测有人靠近后自动唤醒系统,进入身份验证流程,此时设备现象至少应当出现,三色LED从全熄灭转为黄灯亮起,OLED屏幕显示“正在采集”,启动摄像头采集。
4. 当人脸识别通过时,三色LED闪烁3次后,进入语音关键词识别,OLED屏幕显示“正在识别”,当关键词识别正确后,驱动伺服电机旋转一定角度代表开锁,并在 OLED 显示“验证通过”;在超声波传感器检测到无人后,5秒内关门。若在进入语音 识别的20秒内未识别到关键词,回退到人脸识别。  
5. 若超声波传感器检测2分钟内均有人靠近,且在2分钟未通过一次验证,则三色 LED 变红,并记录失败日志。  
6. 四按键用于用户删除、查看日志。 

二、所用硬件简介

  1. ESP32‑S3 Sense:主控板,内置 PSRAM,具备摄像头与 PDM 麦克风,适合端侧 AI 推理。
  2. 超声波传感器:用于检测前方是否有人靠近,触发验证流程。
  3. OLED 显示屏:用于显示系统状态(待机、采集、识别、验证通过、报警等)。
  4. 三色 LED:用于状态指示(黄灯采集/识别,绿灯通过,红灯报警等)。
  5. SG90 舵机:模拟门锁开关动作。
  6. 按键模块:用于查看日志和删除日志。
  7. 摄像头与麦克风:用于人脸识别与语音关键词识别的数据采集与推理。

三、方案框图与项目设计思路

1.方案框图

系统从“感知→识别→决策→执行→记录”依次展开:

框图.jpg

其代码实现主要基于rtos系统的5个任务,由主控任务调度其余4个任务(人脸、语音、ui、超声波)。

2.设计思路

门禁流程天然是“阶段性”的,因此用状态机管理最清晰,根据要求,有如下五种状态:IDLE:待机、FACE:人脸识别VOICE:语音识别UNLOCKED:开锁等待ALARM:报警

但是考虑到人脸/语音推理耗时相对较长(数十ms级别),若放在主循环会卡死 UI 和传感器。人脸/语音推理耗时相对较长,若放在主循环会卡死 UI 和传感器。因此考虑rtos系统,多任务并行,避免阻塞超声波传感器采集持续运行、人脸/语音在触发时运行(节省mcu资源)、OLED/LED/UI 更新始终可响应、控制任务只做逻辑判断。

人脸识别与语音识别模型均通过 Edge Impulse 完成训练。此外,我关注了训练与部署一致性。人脸识别和语音识别的训练采集方式与推理采集方式一致,都使用板载的麦克风、 PDM 麦克风采样,有利于实际使用时的识别。

系统按功能划分为独立模块:face_model 负责人脸推理、voice_model 负责语音推理、ultrasonic 负责超声波距离检测、OLED_Wrapper 负责OLED界面显示、led 负责指示灯、myservo 负责舵机执行、log_manager 负责日志、button 负责按键输入。这种模块化方式使得逻辑清晰、耦合度低,便于管理。

由于供电设计有误,按键模块使用USB供电则会出现最左边的两个按键ADC输入值同为4095,为了提高按键的利用率,使用按键状态机赋予每个按键长按、短按的功能。

 四、调试软件及使用的编程语言

PlatformIO + VS Code作为主要开发环境,C++ 为核心语言。

程序流程图如下:

Face and Voice Task-2026-03-11-134646.png

五、关键代码介绍

1. 状态机 + 任务调度骨架

// --- 状态机定义:把门禁流程分成 5 个状态 ---
enum class SystemState {
IDLE = 0, // 待机
FACE, // 人脸识别
VOICE, // 语音识别
UNLOCKED, // 开锁保持
ALARM // 报警
};
// --- RTOS 任务句柄 + 消息队列 ---
static TaskHandle_t g_task_control = nullptr;
static TaskHandle_t g_task_sensor = nullptr;
static TaskHandle_t g_task_face = nullptr;
static TaskHandle_t g_task_voice = nullptr;
static TaskHandle_t g_task_ui = nullptr;

static QueueHandle_t g_sensor_queue = nullptr;
static QueueHandle_t g_face_queue = nullptr;
static QueueHandle_t g_voice_queue = nullptr;
static QueueHandle_t g_ui_queue = nullptr;
void setup() {
Serial.begin(115200);
while (!Serial) { delay(10); }

MyServo_Init(); // 附注:舵机初始化,开锁/关门动作

// 队列创建:单写多读,解耦任务
g_sensor_queue = xQueueCreate(1, sizeof(SensorEvent));
g_face_queue = xQueueCreate(4, sizeof(FaceEvent));
g_voice_queue = xQueueCreate(4, sizeof(VoiceEvent));
g_ui_queue = xQueueCreate(8, sizeof(UiCommand));

// 启动任务:UI、传感器、人脸、语音、控制
xTaskCreate(Task_UI, "Task_UI", 4096, nullptr, 1, &g_task_ui);
xTaskCreate(Task_Sensor, "Task_Sensor", 4096, nullptr, 2, &g_task_sensor);
xTaskCreate(Task_Face, "Task_Face", 8192, nullptr, 2, &g_task_face);
xTaskCreate(Task_Voice, "Task_Voice", 8192, nullptr, 2, &g_task_voice);
xTaskCreate(Task_Control, "Task_Control", 8192, nullptr, 3, &g_task_control);
}

// loop 空转:控制权交给 RTOS
void loop() { delay(1000); }

2. 超声波事件任务(Task_Sensor

float distance = Ultrasonic_GetDistance();
bool present = (distance > 0.0f && distance < kPresenceThresholdCm);

SensorEvent ev;
ev.present = present;
ev.distance = distance;
ev.ms = millis();
xQueueOverwrite(g_sensor_queue, &ev);

超声波传感器持续测距,判断是否有人靠近门禁,实时把结果送到控制任务。这样主流程不用直接操作传感器,只关心“有人/无人”事件。
Ultrasonic_GetDistance()是根据超声波传感器使用说明编写并接口化的函数,很容易编写:

float Ultrasonic_GetDistance() {
    // 1. 发送至少 10us 的高电平触发信号
    digitalWrite(TRIG_PIN, LOW);
    delayMicroseconds(2);
    digitalWrite(TRIG_PIN, HIGH);
    delayMicroseconds(15);
    digitalWrite(TRIG_PIN, LOW);


    // 2. 读取 Echo 引脚高电平持续的时间(单位:微秒)
    // pulseIn 会等待引脚变高,并记录持续到变低的时间
    long duration = pulseIn(ECHO_PIN, HIGH, 30000); // 30ms 超时(约对应 10/2 米)


    // 3. 计算距离:时间 * 声速 / 2
    // 如果超时返回 0
    if (duration == 0) return 0.0f;
   
    float distance = (duration * 0.0343) / 2;
    return distance;//厘米
}

SensorEvent ev这个结构体用于存储超声波传感器得到的信息,放入队列,进行任务间的通讯。其组成是:

struct SensorEvent {
    bool present;//指示是否检测到人
    float distance;
    uint32_t ms;
};

3. 人脸识别任务

人脸识别计算量大,所以不会一直跑,而是由主控任务通知后才执行一次。这样避免浪费资源,同时还能控制“识别重试间隔”。

while (true) {
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
        if (!inited) {
            if (!face_model::init(true)) {
                inited = false;
                vTaskDelay(pdMS_TO_TICKS(200));
                continue;
            }
            result = face_model::result_create();
            inited = true;
        }
        g_face_busy = true;
        FaceEvent ev = {};
        if (face_model::run_inference(kDebugFace, result)) {
            face_model::print_result(result);
            int idx = face_model::pred_index(result);
            ev.label_index = idx;
            ev.score = (idx >= 0) ? face_model::label_value(result, idx) : 0.0f;
        } else {
            ev.label_index = -1;
            ev.score = 0.0f;
        }
        xQueueSend(g_face_queue, &ev, 0);
        g_face_busy = false;
    }

推理所用到的函数face_model::run_inference(kDebugFace, result)、face_model::pred_index(result)等,是由edge impulse(下文为EI)导出的。值得一提的是,本项目我使用了人脸识别和语音识别两个推理模型,而当同时把两个 EI 模型库加入工程时,它们各自都包含一套 Edge-Impulse SDK这些 SDK 文件里有大量 static 的函数/全局变量(例如

  • DSP相关:spectral_power_edges()periodogram()
  • 推理相关:run_classifier()init_impulse()
  • 后处理相关:ei_print_results()
  • 模型参数:ei_default_impulseei_dsp_blocksei_classifier_inferencing_categories

,编译后在链接阶段变成相同的“全局符号”。

为了解决此问题,我编写了python脚本patch_ei_models.py,用于自动把声音模型文件夹中的 SDK 符号全部加前缀。具体流程是扫描模型库里的 SDK 代码-找到全局符号-批量替换成带模型前缀的名字-让两个模型可以共存。

4. 语音识别任务

语音识别属于“连续检测”,所以在进入语音状态后,任务会持续运行。识别结果不断写入队列,由控制任务判断是否达到“确认条件”。

while (g_voice_active) {
VoiceEvent ev = {};
if (voice_model::run_inference(kDebugVoice, result)) {
ev.confirmed_index = voice_model::confirmed_index(result);
ev.confirmed_value = voice_model::confirmed_value(result);
ev.pred_index = voice_model::pred_index(result);
ev.pred_value = voice_model::pred_value(result);
}
xQueueSend(g_voice_queue, &ev, 0);
vTaskDelay(pdMS_TO_TICKS(20)); // 20ms刷新
}

语音识别不是“一次就判定”,而是使用“确认结果”进行判断。只有当模型确认输出为km(开门)时,才算语音通过,避免误触发。

static bool is_voice_confirmed(const VoiceEvent& ev, const char** label_out, float* value_out) {
if (ev.confirmed_index < 0) return false;
const char* label = voice_model::label_at(ev.confirmed_index);
if (strcmp(label, "km") != 0 ) return false;
if (label_out) *label_out = label;
if (value_out) *value_out = ev.confirmed_value;
return true;
}

5.日志模块

日志模块用于记录关键事件(例如验证失败、语音通过、报警触发),提升系统可追溯性。
设计采用固定长度内存环形缓冲思想

  • 日志未满时直接追加。
  • 日志满时删除最旧记录,把新记录插到末尾。
  • 显示时采用“最新优先”视图(UI 翻页是从最新记录往旧记录看)。
  • 按键短按用于上下翻页,长按用于删除或退出日志界面。
void LogManager::add(const String& text) {
unsigned long t = millis() / 1000;
String line = String(t) + "s " + text;

if (size_ < kMaxLogs) {
logs_[size_] = line;
size_++;
return;
}

// 日志满:整体左移,覆盖最旧
for (int i = 1; i < kMaxLogs; ++i) {
logs_[i - 1] = logs_[i];
}
logs_[kMaxLogs - 1] = line;
}

这里用 millis()/1000 生成简易时间前缀,方便追踪事件发生时刻。当缓冲区满时,通过“左移覆盖”实现“保留最新的 kMaxLogs 条”。

为了符合用户翻页逻辑,即始终从最新往前看,设计如下函数:

bool LogManager::getByViewOffset(int offset, String& out) const {
if (offset < 0 || offset >= size_) return false;
int idx = (size_ - 1) - offset; // UI 偏移 → 内部索引
out = logs_[idx];
return true;
}

offset:0表示最新日志,1表示次新日志(最新优先);内部索引:0表示最早日志,依次递增(先进先出)。本函数将UI的倒序视图映射到内部的正序存储。

6.按钮状态机

按键硬件属于复用输入方式,减少 IO 引脚由于供电设计有误,按键模块使用USB供电则会出现最左边的两个按键ADC输入值同为4095,为了提高按键的利用率,使用按键状态机赋予每个按键长按、短按的功能。

核心思路是:

  • 先用 ADC 判断当前按键编号(1/2/3  -1无按键)。
  • 使用状态机 IDLE → PRESSED → RELEASED
  • 通过时间判断是否超过长按阈值(800ms)。
  • 放开后形成事件队列,由上层统一处理。

通过 ADC 值区间区分不同按键

int getbutton() {
  int val = analogRead(PIN_BUTTON);


  if (val > 200 && val < 3500) return 1;
  if (val > 3500 && val < 4085) return 2;
  if (val > 4085 && val <= 4095) return 3;


  return -1;
}

按键状态机

switch (g_state) {
case BUTTON_STATE_IDLE:
if (btn == 1 || btn == 2 || btn == 3) {
g_activeBtn = btn;
g_pressStartMs = now;
g_longHandled = false;
g_state = BUTTON_STATE_PRESSED;
}
break;

case BUTTON_STATE_PRESSED:
if (btn == g_activeBtn) {
if (!g_longHandled && (now - g_pressStartMs) > kLongPressMs) {
g_longHandled = true;
}
} else if (btn == -1) {
g_releaseStartMs = now;
g_state = BUTTON_STATE_RELEASED;
}
break;

case BUTTON_STATE_RELEASED:
if (btn == -1 && (now - g_releaseStartMs >= kReleaseDebounceMs)) {
unsigned long duration = now - g_pressStartMs;
bool isLong = g_longHandled || duration > kLongPressMs;
g_pendingEvent.button = g_activeBtn;
g_pendingEvent.type = isLong ? BUTTON_EVENT_LONG : BUTTON_EVENT_SHORT;
g_state = BUTTON_STATE_IDLE;
}
break;
}

最终把“短按/长按事件”交给上层模块(如日志查看/删除)。

7.主控任务与时间控制

(1)主控任务的循环结构

while (true) {
// 1) 读取超声波传感器事件
if (xQueueReceive(g_sensor_queue, &s_ev, 0) == pdTRUE) { ... }

// 2) 读取识别事件
bool has_face = (xQueueReceive(g_face_queue, &f_ev, 0) == pdTRUE);
bool has_voice = (xQueueReceive(g_voice_queue, &v_ev, 0) == pdTRUE);

// 3) 获取当前时间
uint32_t now = millis();

// 4) 检测超时/报警条件(循环执行)
if (person_first_seen_ms > 0 && (now - person_first_seen_ms) > kPresenceAlarmMs
&& state != SystemState::UNLOCKED) {
state = SystemState::ALARM;
...
}

// 5) 状态机切换
switch (state) { ... }

// 6) 控制循环频率
vTaskDelay(pdMS_TO_TICKS(20));
}

在主控任务中,有上述循环一直在跑,

每一轮循环都会:

  • 读取超声波事件
  • 读取人脸结果
  • 读取语音结果
  • 读取当前时间 millis()
  • 判断超时 / 报警条件
  • 根据状态机切换状态

最后 vTaskDelay(20ms),保证 CPU 不被独占。

(2)报警触发

当有人靠近后,如果在规定时间内(kPresenceAlarmMs)始终没验证成功,系统就进入报警状态。这段判断每次循环都会执行,所以一旦满足条件就会立即进入alarm状态,触发报警。

if (person_first_seen_ms > 0 && (now - person_first_seen_ms) > kPresenceAlarmMs 
&& state != SystemState::UNLOCKED) {
state = SystemState::ALARM;
if (!alarm_logged) {
g_log.add("规定时间内未通过验证");
alarm_logged = true;
}
}

(3)语音超时回退

语音识别设有最长允许时间(kVoiceTimeoutMs),超过就自动回退到人脸识别。这也依靠循环检测 now-voice_start_ms 来实现。

if ((now - voice_start_ms) > kVoiceTimeoutMs) {
last_person_seen_ms = now;
person_first_seen_ms = now;
g_voice_active = false;
voice_model::stop();
state = SystemState::FACE;
ui_set_led(false, true, false);
ui_send_status("回退到人脸", "采集中...");
last_face_request_ms = 0;
break;
}

类似的,人脸识别也设有回退到home页面的功能。

(4)开锁后无人自动关门

开锁后若超声波检测到无人,到达一定时间就自动关门并回到待机。这个逻辑也在循环中反复检查。

if (!person_present && (now - last_person_seen_ms) > kAlarmExitTimeoutMs) {
MyServo_Turn70AndStop_ne();
ui_set_led(false, false, false);
ui_send_status("HOME", "Bt1:短按+长按删除\nBt2:短按-长按Home");
state = SystemState::IDLE;
}

六、功能展示图及说明

1.实物展示

70092cbdd268b3569ba00bc1151a3772_720.jpg

OLED首页,指引用户短按、长按两个按键的作用。

51291117d8ffbfa46806861e3d33e9c0.jpeg

超声波传感器检测到有人,进入人脸识别。

a4ef0609ea1dcfcd413513b82a41b639.jpeg

人脸识别通过,进入语音识别。

76410bfb9b27aced630556417c7b4852.jpeg

超时未识别到口令,回退到人脸识别。

f0892c95d2ce489cfda097ba73999aa8.jpeg

日志界面,0s启动的日志。

7e116253c393cc6637e2776c3dcfba76.jpeg

未成功通过门禁,记录报警日志及其时间。

1b61e422433521cad8630b7506397773.jpeg

长按删除日志。

5b1f4e972ad6f22a31043b64be1077db.jpeg

2.软件展示

image.png

3.串口展示

按键电压数字量读取

image.png

语音识别串口提示

image.png

人脸识别串口提示

image.png

4.功能展示见视频后半段

七、遇到的问题与其解决

问题 1:双模型冲突(人脸 + 语音 EI 模型无法同时链接)

  • 原因两个模型各自带一套 Edge Impulse SDK,产生全局符号冲突。
  • 解决使用脚本对模型 SDK 进行符号重命名(给语音模型 SDK 增加前缀),让两个模型共存。

问题 2:推理过程导致系统卡顿

  • 原因:推理耗时阻塞主循环
  • 解决:RTOS 多任务拆分,推理任务按需触发

问题 3:按键数量不足

  • 原因:硬件设计失误
  • 解决:采用按键状态机实现长按短按的区分

问题4:语音模型训练准确率 100%,但设备端推理结果仍大量偏向背景音

  • 原因:板载麦克风是 PDM,而EI默认示例按 I2S 方式驱动,数据格式不一致。
  • 解决:改为 PDM 模式配置 I2S,确保采样代码与训练数据一致。

问题5:模型放入 PSRAM很麻烦

  • 原因Edge Impulse 默认用 malloc 在 SRAM 分配,而模型/arena 很大。 
  • 解决:启用 PSRAM,覆写 EI 的分配器让它优先分配到 PSRAM。

问题6:内存压力大

  • 原因:模型参数、Tensor arena(模型最大内存占用)、摄像头 frame buffer (QVGA 320×240,RGB888每像素 3 字节,单帧大小 ≈ 230KB)叠加,片上 SRAM(320KB)不够。
  • 解决:启用 PSRAM,减少并发任务数量,按需触发识别任务而非常驻。

八、总结

本项目完成了端侧 AI 智能门禁与报警系统的完整闭环,涵盖“感知→识别→决策→执行→记录”。通过 ESP32‑S3 Sense 搭载人脸与语音双模型,实现了“人脸+语音”两步验证,并结合超声波唤醒、OLED/LED 状态提示、舵机开关门与日志追溯,形成可演示、可复现的系统。核心技术路线采用 RTOS 多任务与状态机结合,既保证了推理时的实时响应,也避免了 UI 与传感器被阻塞。针对双模型符号冲突、推理卡顿、按键不足、PDM 采样不一致、内存压力等关键问题,均提出并验证了解决方案。


附件下载
AiHardware_26_2_1cpdcpd.zip
platformio工程文件夹
团队介绍
哈尔滨工业大学(威海)-张泽宇
评论
0 / 100
查看更多
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2024 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号