Este tutorial faz parte de: Câmera Makerfabs MaTouch AI ESP32S3 2.8"
A placa MaTouch AI mais recente integra entrada de voz I2S/alto-falante I2S/câmera de 3 milhões OV3660/display de resolução 320*240, com o processador forte ESP32S3 e capacidade Wi-Fi, para tornar esta placa uma boa ferramenta/plataforma para desenvolvimento de IA com ESP32.
Makerfabs MaTouch ESP32-S3 2.8" câmera Detecção facial offline no ESP32-S3 - Sem internet, sem chave de API
Uma rede neural executando no chip - sem internet, sem conta, sem assinatura
Detecção facial executando inteiramente no ESP32-S3. Caixas verdes ao redor dos rostos, pontos vermelhos nos olhos, nariz e boca, e um contador de quadros por segundo em tempo real. Desconecte seu roteador e ele continua funcionando, porque a rede neural está na memória flash do próprio chip e executa em suas instruções vetoriais.
Não há serviço de nuvem aqui, nenhuma chave de API e nada para se cadastrar. Nada sai da sala.
Detecção facial offline executando na placa MaTouch AI ESP32-S3
Como funciona
Isso usa os modelos esp-dl da Espressif: um detector de dois estágios onde human_face_detect_msr01 propõe rostos candidatos e human_face_detect_mnp01 os confirma e encontra os cinco pontos faciais chave. Ambos os modelos vêm dentro do núcleo 2.x do Arduino para ESP32 - não há biblioteca para instalar.
Esta é a razão pela qual todo projeto neste site fixa o núcleo ESP32 2.0.17. Os modelos de rosto foram removidos no núcleo 3.x, e este sketch não compilará lá - você receberá human_face_detect_msr01.hpp: No such file or directory.
Som e um pino de saída
A placa emite um bipe pelo alto-falante quando um rosto aparece, e aciona IO15 em nível alto enquanto um rosto está no quadro - para que possa acionar um relé, uma lâmpada ou uma fechadura. Defina ENABLE_BEEP ou ACTION_PIN no topo do sketch para desativar qualquer um deles.
A câmera e o painel de toque compartilham um barramento
A interface de controle da câmera usa os mesmos dois fios I2C que o painel de toque, o relógio e o medidor de bateria. Por padrão, o driver da câmera instala seu próprio driver I2C nesses pinos e o painel de toque para de responder. A correção são três linhas na configuração da câmera:
config.pin_sccb_sda = -1; // compartilha o barramento que o Wire já aciona
config.pin_sccb_scl = -1;
config.sccb_i2c_port = 0; // Wire é a porta I2C 0com Wire.begin(39, 38) chamado antes de esp_camera_init(). Nenhum exemplo publicado para esta placa faz isso, porque nenhum deles lê o painel de toque após iniciar a câmera.
Limitações honestas
O detector executa em um quadro de 240×240, então rostos pequenos à distância são perdidos.
Detecção não é reconhecimento - este sketch encontra rostos, mas não sabe de quem são. Para isso, veja o projeto 03b.
Não há anti-spoofing. Uma fotografia de um rosto ainda é um rosto.
Sobre a placa MaTouch AI ESP32-S3 2.8"
Cada projeto nesta página executa na MaTouch AI ESP32-S3 2.8" TFT ST7789V da Makerfabs. É uma placa tudo-em-um: uma tela de toque colorida, uma câmera de 3 megapixels, dois microfones e um amplificador de alto-falante real, tudo acionado por um ESP32-S3 com 8 MB de PSRAM. Essa combinação é o que torna esses projetos de IA possíveis em uma única placa sem mais nada conectado.
Os 8 MB de PSRAM importam mais do que qualquer outro número aqui. É isso que permite que a placa mantenha um quadro de câmera, alguns segundos de áudio gravado ou uma foto codificada em base64 na memória ao mesmo tempo - nada disso cabe na RAM normal do ESP32.
Documentação do fabricante: Página wiki da Makerfabs.
Especificações principais
Processador: ESP32-S3, dual core 240 MHz, WiFi 2.4 GHz + Bluetooth 5.0
Memória: 16 MB de flash, 8 MB de PSRAM (necessário para quase todos os projetos aqui)
Display: IPS de 2.8", 320×240, driver ST7789V, SPI
Toque: GT911 capacitivo, rastreia 5 dedos ao mesmo tempo
Câmera: OV3660, 3 megapixels, até 2048×1536
Microfones: dois microfones digitais I2S INMP441 (um par estéreo genuíno)
Alto-falante: amplificador classe-D MAX98357A, 3.2 W em 4 Ω
Armazenamento: slot para cartão microSD (modo SPI)
Alimentação: USB-C, conector de bateria JST, carregador TP4056, interruptor de energia
Também na placa: LED RGB WS2812B, relógio de tempo real com bateria PCF8563T e um medidor de bateria MAX17048 que não está listado nas especificações oficiais
As duas portas USB-C não são iguais. O alto-falante da placa compartilha seus pinos de sinal (IO19 e IO20) com a porta USB nativa, porque esses pinos são as linhas de dados USB fixas do ESP32-S3. Sempre carregue e envie o código pela porta USB-C CH340K (a que fica ao lado do botão RESET) e defina USB CDC On Boot como Disabled. Use a porta errada e o áudio se comportará mal ou os uploads falharão.
Configurações do Arduino IDE
Essas configurações importam. A maioria dos problemas que as pessoas relatam com esta placa é uma delas estar errada, e elas são redefinidas quando você muda a versão do núcleo, então verifique-as novamente após qualquer alteração.
Configuração | Valor |
|---|---|
Placa | Módulo de Desenvolvimento ESP32S3 |
Versão do núcleo ESP32 | 2.0.17 |
PSRAM | OPI PSRAM |
Tamanho da Flash | 16MB (128Mb) |
Esquema de Partições | Flash de 16M (3MB APP/9.9MB FATFS) |
USB CDC na Inicialização | Desativado |
Velocidade de Upload | 921600 |
Apagar Toda a Flash Antes do Upload | Desativado |
Porta | a porta USB-C CH340K |
Use o núcleo ESP32 2.0.17, não o 3.x. A Espressif removeu os modelos de detecção facial no dispositivo no núcleo 3, então os projetos de rosto não compilarão lá. Fixar a versão 2.0.17 mantém todos os projetos desta página funcionando com uma única configuração. No Gerenciador de Placas, o menu suspenso de versão permite alternar de um lado para o outro sempre que desejar.
Use a GFX Library for Arduino versão 1.5.6, não a 1.6.x. As versões 1.6 são criadas para o núcleo ESP32 3 e podem travar na inicialização com o núcleo 2.0.17. Se a tela permanecer preta após o upload, esta é a primeira coisa a verificar.
Bibliotecas necessárias
Instale-as através de Ferramentas → Gerenciar Bibliotecas na IDE Arduino. Os números de versão importam - use os listados.
Biblioteca | Versão | Autor |
|---|---|---|
GFX Library for Arduino | 1.5.6 | moononournation |
bb_captouch | 1.3.1 | Larry Bank |
Os modelos de detecção facial não são uma biblioteca - eles estão incluídos no núcleo ESP32 2.0.17 em si, então não há nada extra para instalar.
Solução de problemas
Sintoma | Causa e correção |
|---|---|
A tela permanece preta | Versão errada da biblioteca GFX (use 1.5.6) ou configurações erradas da placa. |
|
|
Nada é enviado / sem porta COM | Porta USB-C errada, ou o driver CH340 não está instalado. |
A câmera falha e nunca se recupera | A linha de reset da câmera está ligada ao botão RESET da placa, então o software não pode reiniciá-la. Pressione RESET. Se ainda falhar, reconecte o cabo flat da câmera. |
Baixe o código
O esboço Arduino completo para este projeto, juntamente com pins.h e tudo o mais que ele precisa, está disponível para download gratuito.
Descompacte-o, abra o arquivo .ino na IDE Arduino, verifique as configurações acima e faça o upload pela porta USB-C CH340K.
Estes são links de afiliados. Eles não custam nada extra para você e ajudam a apoiar o código gratuito e os tutoriais deste site - obrigado.
Este tutorial é parte de: Câmera Makerfabs MaTouch AI ESP32S3 2.8"
/*
* ===========================================================================
* 03b_Face_Enroll — MaTouch AI ESP32-S3 2.8" TFT ST7789V
* ===========================================================================
*
----------
* ROBOJAX.COM - MaTouch AI ESP32-S3 2.8" project series
*
* WATCH THE VIDEO
* https://youtu.be/6AL3g3tC_Hk
*
* WRITTEN TUTORIALS - every project, with photos and full explanation
* Camera and touchscreen.... https://robojax.com/RTJ849
* Offline face recognition.. https://robojax.com/RTJ850
* AI voice assistant........ https://robojax.com/RTJ851
* AI vision................. https://robojax.com/RTJ852
*
* GET THE BOARD - SAVE $5 with coupon code: Robojax_Makerfab
* https://www.makerfabs.com/matouch-ai-esp32s3-2-8-tft-st7789v.html
* (enter the code at checkout)
*
* All of this code is free. If it helped you, a subscribe on YouTube is
* the best way to support more of it.
*
*
* Watching the video first will save you time - it shows the Arduino IDE
* settings and the library versions being set up step by step.
* ---------------------------------------------------------------------------
*
* Guided, named face enrollment - fully offline. This is 03_Face_Offline
* grown up: instead of a bare ENROLL button, you manage a roster of names
* over the serial monitor, then enroll each person with an on-screen guide,
* a countdown, and multi-sample capture.
*
* HOW TO USE
* ----------
* 1. Open the Serial Monitor at 115200 and set up your roster ONCE:
* name 0 Ahmad
* name 1 Sara
* list
* Names are stored in flash (NVS) - they survive reboots.
*
* 2. On the board: tap ENROLL. The top-left shows "Enroll: Ahmad".
* Tap again to cycle to the next name. Cycling past the last name
* cancels enrollment.
*
* 3. The chosen person steps in front of the camera, close enough that
* their face fills the guide box. A 3-2-1 countdown runs, three samples
* are captured, and a green "enrolled" banner confirms. Enrollment then
* disarms itself.
*
* 4. From now on the board greets that face BY NAME on screen, SAYS THE
* WELCOME OUT LOUD if the matching clip is on the SD card, and the action
* pin (IO15) goes HIGH only for enrolled faces - face unlock.
*
* SPOKEN WELCOMES (optional - the sketch works fine without them)
* ---------------------------------------------------------------
* Put WAV files in the root of the SD card, one per roster slot:
* /welcome_0.wav plays for whoever is "name 0"
* /welcome_1.wav plays for "name 1" ... up to /welcome_7.wav
* Format: 16-bit PCM WAV (mono preferred; stereo is downmixed). Any sample
* rate works. Missing card or missing file -> the triple chirp plays instead.
*
* Generate them in one go with Makerfabs/make_welcome_wavs.ps1 , which uses
* Azure text-to-speech. Azure returns exactly 16 kHz / 16-bit / mono PCM -
* precisely what this board's amplifier wants. The cloud is used ONCE, when
* you make the files; the board itself never needs the internet.
*
* Test a clip without a face in front of the camera: play 0 (serial)
*
* Serial commands:
* list show the roster and who is enrolled this session
* name <n> <text> set the name for slot n (0-7)
* del <n> forget slot n's face for this session (name kept)
* clear wipe all names from flash + forget all faces
* help this list
*
* HONEST LIMITATION (say it on camera): names persist across reboots,
* enrolled FACES do not - they live in RAM and are re-enrolled per session
* (~10 seconds per person). A permanent installation would add a custom
* flash partition for face storage.
*
* ---------------------------------------------------------------------------
* TUNING KNOBS (the #defines just below this comment)
*
* SAMPLES_PER_FACE (default 3)
* How many face samples are captured per enrollment, ~400 ms apart.
* More samples = more reliable recognition but a longer capture moment.
* 3 is a good balance; use 4-5 if recognition feels unsure (glasses
* on/off, strong side light), 2 if you want the fastest possible demo.
* Each sample consumes one of the MAX_IDS (32) model slots, so
* 8 people x 3 samples = 24 fits; 8 x 5 = 40 does NOT.
*
* GATE_MIN_WIDTH (default 90)
* How close the person must be before capture arms (face box width in
* pixels, out of 240). Raise it to force closer/better enrollments,
* lower it if people cannot get close to the lens in your setup.
*
* ---------------------------------------------------------------------------
* BOARD SETTINGS (Tools menu - EVERY line matters, wrong = black screen
* or compile errors. These reset when you switch cores - recheck them!)
*
* Board : ESP32S3 Dev Module
* ESP32 core : 2.0.17 <-- REQUIRED. Face models were removed
* from core 3.x - will not compile there
* PSRAM : OPI PSRAM <-- required, nothing works without
* Flash Size : 16MB (128Mb)
* Partition Scheme : 16M Flash (3MB APP/9.9MB FATFS)
* USB CDC On Boot : Disabled
* Upload Speed : 921600
* Port : the CH340K USB-C port (the one near RESET)
*
* Erase All Flash Contents Before Sketch Upload : DISABLED
* ^^^ If this is Enabled, every upload wipes the non-volatile
* memory where the roster names live, and your names vanish on
* each reflash. This is the usual reason "the names were erased".
*
* LIBRARIES
* GFX Library for Arduino v1.5.6 (NOT 1.6.x - that pairs with core 3)
* bb_captouch v1.3.1
* (face models and Preferences come with the ESP32 core itself)
*
* ---------------------------------------------------------------------------
* FUNCTIONS IN THIS SKETCH
* rosterLoad() load the 8 names from NVS flash at boot
* rosterSaveName(slot) save one name to NVS
* rosterClearAll() wipe names + session faces ("clear" command)
* namedCount() / enrolledCount() / nextNamedSlot(from) roster queries
* printHelp() / printRoster() serial command help + roster table
* handleSerialLine(l) parse one serial command (list/name/del/clear)
* pollSerial() non-blocking serial line reader
* spkInit() I2S speaker output
* playTone(f,ms) synthesised tone
* beepSeen()/beepEnrolled()/beepTick()/beepRecognized() event sounds
* playWavFromSD(path) play ANY WAV file on the SD card, by name
* playWelcome(slot) play /welcome_<slot>.wav, by roster slot number
* drawGreeting() the big latched NAME banner
* touchRaw(&x,&y) raw touch read, mapped to screen coordinates
* touchTapped(&x,&y) true exactly once per physical tap (edge detect)
* cameraInit() camera at 240x240 RGB565, shared I2C bus
* gatePasses(results) quality gate: one face, close, centred
* drawPanel(faces,fps) right-hand info column + ENROLL button
* overlay(msg,col) top strip inside the viewfinder
* bottomBar(msg,col) recognised-name strip at the bottom
* setup() / loop() boot / detect + recognise + enrollment machine
*
* Robojax.com
* ===========================================================================
*/
#define ENABLE_BEEP 1 // speaker chirps on events (IO19/20 - see pins.h)
#define ACTION_PIN 15 // HIGH while an ENROLLED face is in frame; -1 = off
#define ROSTER_SIZE 8
#define NAME_LEN 16
#define SAMPLES_PER_FACE 3 // multi-sample enrollment = reliable recognition
#define GATE_MIN_WIDTH 90 // face box must be at least this wide (come close)
#define ARMED_TIMEOUT_MS 30000
/* How long the big NAME banner stays on screen after the last time that face
* was seen. It refreshes every time recognition succeeds, so while the person
* is in front of the camera the banner simply stays up; when they walk away
* it lingers this long and then clears. */
#define GREET_HOLD_MS 6000
#include <Arduino_GFX_Library.h>
#include <bb_captouch.h>
#include <Wire.h>
#include <Preferences.h>
#include <SPI.h>
#include <SD.h>
#include "esp_camera.h"
#include "pins.h"
#include "human_face_detect_msr01.hpp"
#include "human_face_detect_mnp01.hpp"
#include "face_recognition_tool.hpp"
#include "face_recognition_112_v1_s8.hpp"
#if ENABLE_BEEP
#include "driver/i2s.h"
#endif
/* HWSPI (not ESP32SPI): the SD card holding the welcome clips shares pins
* 13/12/48 with the display, so BOTH must go through the same SPI driver.
* Using two different drivers silently steals the pins from the LCD. */
Arduino_HWSPI *bus = new Arduino_HWSPI(
TFT_DC, TFT_CS, TFT_SCLK, TFT_MOSI, TFT_MISO, &SPI, true);
Arduino_GFX *gfx = new Arduino_ST7789(bus, TFT_RES, 1, true);
BBCapTouch bbct;
HumanFaceDetectMSR01 stage1(0.1F, 0.5F, 10, 0.2F);
HumanFaceDetectMNP01 stage2(0.5F, 0.3F, 5);
FaceRecognition112V1S8 recognizer;
Preferences prefs;
/* --- roster --------------------------------------------------------------- */
char names[ROSTER_SIZE][NAME_LEN]; // loaded from NVS at boot
uint8_t samples_of[ROSTER_SIZE] = {0}; // samples enrolled this session
/* esp-dl hands out sequential ids (0,1,2...) as we enroll. This maps each
* model id back to a roster slot; -1 = forgotten (del command). */
#define MAX_IDS 32
int8_t id2slot[MAX_IDS];
int ids_used = 0;
/* --- enrollment state machine --------------------------------------------- */
enum EnrollState { EN_IDLE, EN_ARMED, EN_COUNTDOWN, EN_CAPTURE, EN_DONE };
EnrollState en_state = EN_IDLE;
int en_slot = -1; // roster slot being enrolled
uint32_t en_t0 = 0; // timer for the current state
int en_count = 0; // countdown value / samples captured
uint32_t en_last_sample = 0;
/* --- layout --------------------------------------------------------------- */
#define BTN_X 242
#define BTN_W 78
#define BTN_ENROLL_Y 140 // ENROLL / NEXT
#define BTN_CLEAR_Y 92 // CLEAR - wipes the banner / cancels enrollment
#define BTN_H 44
/* The top VIEW_TOP rows of the viewfinder are RESERVED for the status text.
* The camera image starts below them, so that text is never erased and
* therefore never flickers. Camera row N still lands on screen row N, so the
* detection boxes need no offset - they are simply clipped at the top. */
#define VIEW_TOP 18
/* --- serial line buffer ---------------------------------------------------- */
char ser_line[48];
int ser_len = 0;
/* --- the "hello <name>" banner --------------------------------------------
* Latched, not drawn per-frame-of-recognition: the detector occasionally
* misses a frame, and without latching the name would flicker on and off as
* the camera repaints over it. */
char greet_name[NAME_LEN] = "";
float greet_conf = 0;
uint32_t greet_until = 0; // millis() deadline; 0 = nothing showing
/* --- spoken welcome clips -------------------------------------------------
* Optional WAV files on the SD card, one per roster slot:
* /welcome_0.wav /welcome_1.wav ... /welcome_7.wav
* Slot 0 is whoever you set with "name 0 Ahmad", so adding a person is:
* set the name, drop the matching file, enroll the face.
* If the card or the file is missing, the triple chirp plays instead -
* the sketch never depends on the SD card being present.
*
* Generate the files with make_welcome_wavs.ps1 (in the Makerfabs folder):
* Azure returns exactly 16 kHz / 16-bit / mono PCM, which is precisely what
* this board's amplifier wants - so the cloud is used ONCE at build time and
* never at run time. */
bool ok_sd = false;
int pending_welcome = -1; // slot whose clip should play after drawing
/* ===========================================================================
* Roster storage — names in NVS, keys "n0".."n7"
* =========================================================================== */
void rosterLoad() {
bool opened = prefs.begin("faces", true); // read-only
int found = 0;
for (int i = 0; i < ROSTER_SIZE; i++) {
char key[4];
snprintf(key, sizeof(key), "n%d", i);
String v = prefs.getString(key, "");
strncpy(names[i], v.c_str(), NAME_LEN - 1);
names[i][NAME_LEN - 1] = 0;
if (names[i][0]) found++;
}
prefs.end();
Serial.printf("NVS \"faces\": %s, %d name(s) restored\n",
opened ? "found" : "not present",
found);
if (!opened || found == 0)
Serial.println(F(" (first run, or the flash was erased - see the note about\n"
" 'Erase All Flash Contents Before Sketch Upload' in Tools)"));
}
/* Writes the name AND reads it straight back, so a silent NVS failure can
* never look like success. */
void rosterSaveName(int slot) {
char key[4];
snprintf(key, sizeof(key), "n%d", slot);
prefs.begin("faces", false); // read-write
size_t written = prefs.putString(key, names[slot]);
String back = prefs.getString(key, "");
prefs.end();
bool ok = (written > 0) && (back == String(names[slot]));
Serial.printf("slot %d = \"%s\" -> flash %s\n",
slot, names[slot], ok ? "OK (survives reboot)" : "WRITE FAILED");
if (!ok)
Serial.println(F(" NVS write failed - is the partition scheme still\n"
" '16M Flash (3MB APP/9.9MB FATFS)'?"));
}
void rosterClearAll() {
prefs.begin("faces", false);
prefs.clear();
prefs.end();
memset(names, 0, sizeof(names));
memset(samples_of, 0, sizeof(samples_of));
for (int i = 0; i < MAX_IDS; i++) id2slot[i] = -1;
}
int namedCount() {
int n = 0;
for (int i = 0; i < ROSTER_SIZE; i++) if (names[i][0]) n++;
return n;
}
int enrolledCount() {
int n = 0;
for (int i = 0; i < ROSTER_SIZE; i++) if (samples_of[i]) n++;
return n;
}
/* next named slot at or after 'from'; -1 if none */
int nextNamedSlot(int from) {
for (int i = from; i < ROSTER_SIZE; i++) if (names[i][0]) return i;
return -1;
}
/* ===========================================================================
* Serial commands
* =========================================================================== */
void printHelp() {
Serial.println(F("\nCommands:"));
Serial.println(F(" list show roster"));
Serial.println(F(" name <n> <text> set name for slot n (0-7)"));
Serial.println(F(" del <n> forget slot n's face (this session; name kept)"));
Serial.println(F(" play <n> test-play /welcome_<n>.wav from the SD card"));
Serial.println(F(" clear wipe all names + faces (reboot for a full reset)"));
Serial.println(F(" help this list\n"));
}
void printRoster() {
Serial.println(F("\nslot name enrolled"));
Serial.println(F("---- --------------- --------"));
for (int i = 0; i < ROSTER_SIZE; i++) {
Serial.printf(" %d %-15s %s\n", i,
names[i][0] ? names[i] : "-",
samples_of[i] ? "YES" : "no");
}
Serial.printf("Names persist in flash. Faces are RAM-only (%d enrolled this session).\n\n",
enrolledCount());
}
void handleSerialLine(char *line) {
while (*line == ' ') line++;
if (strncmp(line, "list", 4) == 0) {
printRoster();
} else if (strncmp(line, "name ", 5) == 0) {
int slot = -1;
char text[NAME_LEN] = {0};
if (sscanf(line + 5, "%d %15[^\n]", &slot, text) == 2 &&
slot >= 0 && slot < ROSTER_SIZE && text[0]) {
strncpy(names[slot], text, NAME_LEN - 1);
rosterSaveName(slot);
Serial.printf("slot %d = \"%s\" (saved to flash)\n", slot, names[slot]);
} else {
Serial.println(F("usage: name <0-7> <text>"));
}
} else if (strncmp(line, "del ", 4) == 0) {
int slot = atoi(line + 4);
if (slot >= 0 && slot < ROSTER_SIZE) {
// esp-dl on core 2.x has no reliable per-id delete, so we unmap instead:
// the samples stay in the model but now report as "unknown".
for (int i = 0; i < MAX_IDS; i++) if (id2slot[i] == slot) id2slot[i] = -1;
samples_of[slot] = 0;
Serial.printf("slot %d face forgotten (name \"%s\" kept)\n", slot, names[slot]);
}
} else if (strncmp(line, "play ", 5) == 0) {
/* Test a welcome clip without needing a face in front of the camera. */
int slot = atoi(line + 5);
if (!playWelcome(slot))
Serial.printf("no /welcome_%d.wav (or no SD card)\n", slot);
} else if (strncmp(line, "clear", 5) == 0) {
rosterClearAll();
Serial.println(F("all names wiped from flash, all faces forgotten."));
Serial.println(F("(reboot to also empty the face model itself)"));
} else {
printHelp();
}
}
void pollSerial() {
while (Serial.available()) {
char c = Serial.read();
if (c == '\n' || c == '\r') {
if (ser_len > 0) {
ser_line[ser_len] = 0;
handleSerialLine(ser_line);
ser_len = 0;
}
} else if (ser_len < (int)sizeof(ser_line) - 1) {
ser_line[ser_len++] = c;
}
}
}
/* ===========================================================================
* Beep — event sounds. One chirp = face seen, rising pair = enrolled.
* =========================================================================== */
#if ENABLE_BEEP
void spkInit() {
i2s_config_t cfg = {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
.sample_rate = 16000,
.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
.dma_buf_count = 4,
.dma_buf_len = 256,
.use_apll = false,
.tx_desc_auto_clear = true,
.fixed_mclk = 0
};
i2s_pin_config_t pins = {
.mck_io_num = I2S_PIN_NO_CHANGE,
.bck_io_num = I2S_SPK_BCLK,
.ws_io_num = I2S_SPK_LRC,
.data_out_num = I2S_SPK_DOUT,
.data_in_num = I2S_PIN_NO_CHANGE
};
i2s_driver_install(I2S_SPK_PORT, &cfg, 0, NULL);
i2s_set_pin(I2S_SPK_PORT, &pins);
i2s_zero_dma_buffer(I2S_SPK_PORT);
}
void playTone(float freq, int ms) {
int n = 16 * ms;
int16_t *buf = (int16_t *)malloc(n * 2);
if (!buf) return;
int fade = n / 8;
for (int i = 0; i < n; i++) {
float env = 1.0f;
if (i < fade) env = (float)i / fade;
else if (i > n - fade) env = (float)(n - i) / fade;
buf[i] = (int16_t)(sinf(2 * PI * freq * i / 16000.0f) * 11000 * env);
}
size_t w = 0;
i2s_write(I2S_SPK_PORT, buf, n * 2, &w, portMAX_DELAY);
free(buf);
}
/* playTone(0, ms) is silence - sin(0) is zero - which is how the triple
* beep gets clean gaps between its chirps. */
void beepSeen() { playTone(1000, 90); } // a face arrived
void beepEnrolled() { playTone(900, 90); playTone(1400, 140); } // enrollment done
void beepTick() { playTone(1200, 45); } // countdown tick
/* THREE quick high chirps = "I know who this is". Deliberately different
* from the single beep that just means "somebody is there". */
void beepRecognized() {
playTone(1600, 80);
playTone(0, 70);
playTone(1600, 80);
playTone(0, 70);
playTone(1600, 80);
}
/* ===========================================================================
* playWavFromSD(path) — play any WAV file on the SD card, by name.
*
* playWavFromSD("/welcome_0.wav");
* playWavFromSD("/doorbell.wav");
*
* Returns true if something was played. Handles 16-bit PCM, mono or stereo
* (stereo is downmixed), at any sample rate - the I2S clock is retuned to
* match the file and put back to 16 kHz afterwards so the beeps still work.
*
* The whole clip is loaded into PSRAM before playing: reading the SD card
* in the middle of playback would stutter, because the card shares the SPI
* bus with the display.
* =========================================================================== */
bool playWavFromSD(const char *path) {
if (!ok_sd) { Serial.printf("no SD card - cannot play %s\n", path); return false; }
File f = SD.open(path, FILE_READ);
if (!f) { Serial.printf("not found: %s\n", path); return false; }
char riff[12];
if (f.read((uint8_t *)riff, 12) != 12 ||
memcmp(riff, "RIFF", 4) || memcmp(riff + 8, "WAVE", 4)) {
Serial.printf("%s is not a WAV file\n", path);
f.close();
return false;
}
uint32_t rate = 16000, data_len = 0;
uint16_t channels = 1, bits = 16;
/* walk the RIFF chunks to find "fmt " and "data" - do not assume the
* header is exactly 44 bytes, some encoders add extra chunks */
while (f.available() >= 8) {
char cid[4];
uint32_t csize = 0;
f.read((uint8_t *)cid, 4);
f.read((uint8_t *)&csize, 4);
if (!memcmp(cid, "fmt ", 4)) {
uint8_t fmt[16] = {0};
uint32_t want = csize < 16 ? csize : 16;
f.read(fmt, want);
channels = fmt[2] | (fmt[3] << 8);
rate = (uint32_t)fmt[4] | ((uint32_t)fmt[5] << 8) |
((uint32_t)fmt[6] << 16) | ((uint32_t)fmt[7] << 24);
bits = fmt[14] | (fmt[15] << 8);
if (csize > want) f.seek(f.position() + (csize - want));
} else if (!memcmp(cid, "data", 4)) {
data_len = csize;
break; // now positioned at the samples
} else {
f.seek(f.position() + csize + (csize & 1)); // chunks are word-aligned
}
}
if (bits != 16 || data_len == 0) {
Serial.printf("%s: need 16-bit PCM (got %u-bit, %u bytes)\n",
path, bits, (unsigned)data_len);
f.close();
return false;
}
const uint32_t CAP = 1024UL * 1024UL; // ~30 s at 16 kHz mono
if (data_len > CAP) data_len = CAP;
uint8_t *pcm = (uint8_t *)ps_malloc(data_len);
if (!pcm) { Serial.println("PSRAM alloc failed"); f.close(); return false; }
uint32_t got = f.read(pcm, data_len);
f.close();
if (got < 64) { free(pcm); return false; }
/* stereo -> mono, in place */
if (channels == 2) {
int16_t *s = (int16_t *)pcm;
uint32_t pairs = got / 4;
for (uint32_t i = 0; i < pairs; i++)
s[i] = (int16_t)(((int32_t)s[i * 2] + s[i * 2 + 1]) / 2);
got = pairs * 2;
}
Serial.printf("playing %s (%u KB, %u Hz, %u ch)\n",
path, (unsigned)(got / 1024), (unsigned)rate, channels);
i2s_set_sample_rates(I2S_SPK_PORT, rate);
static const uint8_t lead_in[320] = {0}; // brief silence softens the pop
size_t w = 0;
i2s_write(I2S_SPK_PORT, lead_in, sizeof(lead_in), &w, portMAX_DELAY);
i2s_write(I2S_SPK_PORT, pcm, got, &w, portMAX_DELAY);
i2s_write(I2S_SPK_PORT, lead_in, sizeof(lead_in), &w, portMAX_DELAY);
delay(120);
i2s_zero_dma_buffer(I2S_SPK_PORT);
i2s_set_sample_rates(I2S_SPK_PORT, 16000); // back to the beep rate
free(pcm);
return true;
}
/* playWelcome(slot) — same thing, addressed by roster slot number.
* Plays /welcome_<slot>.wav . Returns false if there is no such file, so
* the caller can fall back to the chirps. */
bool playWelcome(int slot) {
if (slot < 0 || slot >= ROSTER_SIZE) return false;
char path[24];
snprintf(path, sizeof(path), "/welcome_%d.wav", slot);
return playWavFromSD(path);
}
#else
bool playWavFromSD(const char *path) { (void)path; return false; }
bool playWelcome(int slot) { (void)slot; return false; }
void beepSeen() {}
void beepEnrolled() {}
void beepTick() {}
void beepRecognized() {}
#endif
/* ===========================================================================
* Touch — edge-detected: one event per physical tap
* =========================================================================== */
bool touchRaw(uint16_t *x, uint16_t *y) {
TOUCHINFO ti;
if (!bbct.getSamples(&ti)) return false;
if (ti.count < 1) return false;
*x = ti.y[0];
*y = (ti.x[0] > 240) ? 0 : (240 - ti.x[0]);
return true;
}
/* returns true exactly once per tap, with the tap position */
bool touchTapped(uint16_t *tx, uint16_t *ty) {
static bool down = false;
static uint8_t misses = 0;
uint16_t x, y;
if (touchRaw(&x, &y)) {
misses = 0;
if (!down) { down = true; *tx = x; *ty = y; return true; }
} else if (down) {
if (++misses >= 4) { down = false; misses = 0; }
}
return false;
}
/* ===========================================================================
* Camera — identical to the proven 03 init (shared SCCB on Wire)
* =========================================================================== */
bool cameraInit() {
camera_config_t c;
c.ledc_channel = LEDC_CHANNEL_0;
c.ledc_timer = LEDC_TIMER_0;
c.pin_d0 = CAM_PIN_D0; c.pin_d1 = CAM_PIN_D1;
c.pin_d2 = CAM_PIN_D2; c.pin_d3 = CAM_PIN_D3;
c.pin_d4 = CAM_PIN_D4; c.pin_d5 = CAM_PIN_D5;
c.pin_d6 = CAM_PIN_D6; c.pin_d7 = CAM_PIN_D7;
c.pin_xclk = CAM_PIN_XCLK;
c.pin_pclk = CAM_PIN_PCLK;
c.pin_vsync = CAM_PIN_VSYNC;
c.pin_href = CAM_PIN_HREF;
c.pin_sccb_sda = -1; // share the bus Wire already drives
c.pin_sccb_scl = -1;
c.sccb_i2c_port = 0;
c.pin_pwdn = CAM_PIN_PWDN;
c.pin_reset = CAM_PIN_RESET;
c.xclk_freq_hz = 20000000;
c.frame_size = FRAMESIZE_240X240;
c.pixel_format = PIXFORMAT_RGB565;
c.grab_mode = CAMERA_GRAB_WHEN_EMPTY;
c.fb_location = CAMERA_FB_IN_PSRAM;
c.jpeg_quality = 12;
c.fb_count = 2;
if (esp_camera_init(&c) != ESP_OK) return false;
sensor_t *s = esp_camera_sensor_get();
if (s) { s->set_hmirror(s, 1); s->set_vflip(s, 1); s->set_brightness(s, 1); }
return true;
}
/* ===========================================================================
* Quality gate: exactly one face, close enough, roughly centred
* =========================================================================== */
bool gatePasses(std::list<dl::detect::result_t> &results) {
if (results.size() != 1) return false;
dl::detect::result_t &r = results.front();
int w = r.box[2] - r.box[0];
int cx = (r.box[0] + r.box[2]) / 2;
int cy = (r.box[1] + r.box[3]) / 2;
if (w < GATE_MIN_WIDTH) return false;
if (cx < 48 || cx > 192) return false;
if (cy < 48 || cy > 192) return false;
return true;
}
/* ===========================================================================
* UI
* =========================================================================== */
void drawPanel(int faces, float fps) {
gfx->fillRect(BTN_X, 0, BTN_W, 240, BLACK);
gfx->setTextSize(1);
gfx->setTextColor(CYAN);
gfx->setCursor(BTN_X + 2, 4); gfx->print("OFFLINE");
gfx->setCursor(BTN_X + 2, 14); gfx->print("face enroll");
gfx->setCursor(BTN_X + 2, 24); gfx->print("Robojax.com");
gfx->setTextColor(WHITE);
gfx->setCursor(BTN_X + 2, 44); gfx->printf("faces: %d", faces);
gfx->setCursor(BTN_X + 2, 56); gfx->printf("FPS: %.1f", fps);
gfx->setCursor(BTN_X + 2, 68); gfx->printf("known: %d/%d", enrolledCount(), namedCount());
// CLEAR button - wipes the name banner, or cancels an enrollment in progress
gfx->fillRoundRect(BTN_X, BTN_CLEAR_Y, BTN_W, BTN_H, 6, gfx->color565(110, 35, 35));
gfx->drawRoundRect(BTN_X, BTN_CLEAR_Y, BTN_W, BTN_H, 6, WHITE);
gfx->setTextColor(WHITE);
gfx->setCursor(BTN_X + 18, BTN_CLEAR_Y + 18);
gfx->print("CLEAR");
// ENROLL button
uint16_t col = (en_state == EN_IDLE) ? gfx->color565(0, 90, 160)
: gfx->color565(160, 90, 0);
gfx->fillRoundRect(BTN_X, BTN_ENROLL_Y, BTN_W, BTN_H, 6, col);
gfx->drawRoundRect(BTN_X, BTN_ENROLL_Y, BTN_W, BTN_H, 6, WHITE);
gfx->setTextColor(WHITE);
gfx->setCursor(BTN_X + 14, BTN_ENROLL_Y + 16);
gfx->print(en_state == EN_IDLE ? "ENROLL" : "NEXT >");
gfx->setTextColor(YELLOW);
gfx->setCursor(BTN_X + 2, 196); gfx->print("names kept,");
gfx->setCursor(BTN_X + 2, 206); gfx->print("faces reset");
gfx->setCursor(BTN_X + 2, 216); gfx->print("at boot");
}
/* Top status strip. Redraws ONLY when the text actually changes - the camera
* never covers this strip, so anything drawn here stays put and cannot
* flicker. Call overlay("") to clear it. */
void overlay(const char *msg, uint16_t colour) {
static char last[72] = "\x01"; // impossible value = force first draw
static uint16_t last_col = 0;
if (colour == last_col && strncmp(last, msg, sizeof(last) - 1) == 0) return;
strncpy(last, msg, sizeof(last) - 1);
last[sizeof(last) - 1] = 0;
last_col = colour;
gfx->fillRect(0, 0, 240, VIEW_TOP, BLACK);
if (!msg[0]) return;
gfx->setTextSize(1);
gfx->setTextColor(colour);
gfx->setCursor(4, 5);
gfx->print(msg);
}
void bottomBar(const char *msg, uint16_t colour) {
gfx->fillRect(0, 224, 240, 16, BLACK);
gfx->setTextSize(1);
gfx->setTextColor(colour);
gfx->setCursor(4, 228);
gfx->print(msg);
}
/* The big green NAME banner. Redrawn every frame while the greeting is live,
* so the camera never paints over it. */
void drawGreeting() {
const int bx = 6, by = 152, bw = 228, bh = 62;
gfx->fillRoundRect(bx, by, bw, bh, 8, gfx->color565(0, 120, 45));
gfx->drawRoundRect(bx, by, bw, bh, 8, WHITE);
int len = strlen(greet_name);
int ts = (len <= 11) ? 3 : 2; // shrink for very long names
int tw = len * 6 * ts;
gfx->setTextSize(ts);
gfx->setTextColor(WHITE);
gfx->setCursor(bx + (bw - tw) / 2, by + 10);
gfx->print(greet_name);
gfx->setTextSize(1);
gfx->setTextColor(gfx->color565(200, 255, 200));
gfx->setCursor(bx + 8, by + bh - 16);
gfx->printf("RECOGNIZED %.0f%% offline", greet_conf);
}
/* ===========================================================================
* SETUP
* =========================================================================== */
void setup() {
Serial.begin(115200);
delay(400);
Serial.println(F("\n=== 03b Face Enrollment | Robojax.com ==="));
for (int i = 0; i < MAX_IDS; i++) id2slot[i] = -1;
rosterLoad();
pinMode(TFT_BLK, OUTPUT);
digitalWrite(TFT_BLK, LOW);
pinMode(SD_CS, OUTPUT);
digitalWrite(SD_CS, HIGH);
// one shared SPI bus for the display AND the SD card, started before either
SPI.begin(TFT_SCLK, TFT_MISO, TFT_MOSI);
#if ACTION_PIN >= 0
pinMode(ACTION_PIN, OUTPUT);
digitalWrite(ACTION_PIN, LOW);
#endif
gfx->begin();
gfx->fillScreen(BLACK);
digitalWrite(TFT_BLK, HIGH);
bbct.init(TOUCH_SDA, TOUCH_SCL, TOUCH_RST, TOUCH_INT);
delay(50);
Wire.begin(I2C_SDA, I2C_SCL, 100000);
delay(20);
#if ENABLE_BEEP
spkInit();
#endif
gfx->setTextColor(YELLOW);
gfx->setTextSize(1);
gfx->setCursor(4, 4);
gfx->print("starting camera + neural networks...");
if (!cameraInit()) {
gfx->fillScreen(RED);
gfx->setTextColor(WHITE);
gfx->setTextSize(2);
gfx->setCursor(20, 100);
gfx->print("CAMERA FAILED");
gfx->setTextSize(1);
gfx->setCursor(20, 130);
gfx->print("Press RESET (camera reset = board reset)");
while (1) delay(1000);
}
/* SD card LAST - after the camera. The camera's DMA buffer must come from
* internal RAM, and mounting the card first can starve it ("cam_dma_config
* failed"). The card is entirely optional here: no card just means the
* triple chirp is used instead of spoken welcomes. */
const uint32_t sd_speeds[] = {40000000, 20000000, 10000000, 4000000};
for (uint8_t i = 0; i < 4 && !ok_sd; i++)
if (SD.begin(SD_CS, SPI, sd_speeds[i])) ok_sd = true;
digitalWrite(SD_CS, HIGH);
Serial.println(ok_sd ? "SD ok - looking for /welcome_<slot>.wav clips"
: "no SD card - using chirps instead of spoken welcomes");
gfx->fillScreen(BLACK);
drawPanel(0, 0);
printRoster();
printHelp();
if (namedCount() == 0)
Serial.println(F(">> Roster is empty. Start with: name 0 YourName"));
}
/* ===========================================================================
* LOOP
* =========================================================================== */
void loop() {
uint32_t t0 = millis();
pollSerial();
camera_fb_t *fb = esp_camera_fb_get();
if (!fb) { delay(10); return; }
std::list<dl::detect::result_t> &candidates =
stage1.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3});
std::list<dl::detect::result_t> &results =
stage2.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}, candidates);
int faces = results.size();
bool known_face = false;
char who[36] = "";
/* ---- recognition (skip while capturing - the samples ARE the model) ---- */
if (faces > 0 && en_state != EN_CAPTURE && ids_used > 0) {
dl::detect::result_t &best = results.front();
face_info_t info = recognizer.recognize((uint16_t *)fb->buf,
{(int)fb->height, (int)fb->width, 3},
best.keypoint);
if (info.id >= 0 && info.id < MAX_IDS && id2slot[info.id] >= 0) {
known_face = true;
const char *nm = names[id2slot[info.id]];
/* A greeting is "new" if it is a different person, or if the previous
* banner had already expired (they walked away and came back). Only a
* new greeting chirps - otherwise it would beep every frame. */
bool is_new = (strcmp(greet_name, nm) != 0) || (millis() > greet_until);
strncpy(greet_name, nm, NAME_LEN - 1);
greet_name[NAME_LEN - 1] = 0;
greet_conf = info.similarity * 100;
greet_until = millis() + GREET_HOLD_MS;
if (is_new) {
Serial.printf("recognized: %s (%.0f%%)\n", nm, greet_conf);
/* Queue the clip instead of playing it here: the name banner is
* drawn further down the loop, and we want the viewer to SEE the
* name before the board says it. */
pending_welcome = id2slot[info.id];
}
} else if (info.id >= 0) {
snprintf(who, sizeof(who), "forgotten face");
} else {
snprintf(who, sizeof(who), "unknown");
}
}
/* ---- enrollment state machine ---- */
bool gate = gatePasses(results);
switch (en_state) {
case EN_IDLE:
break;
case EN_ARMED:
if (millis() - en_t0 > ARMED_TIMEOUT_MS) {
en_state = EN_IDLE;
Serial.println(F("enrollment timed out"));
} else if (gate) {
en_state = EN_COUNTDOWN;
en_count = 3;
en_t0 = millis();
beepTick();
}
break;
case EN_COUNTDOWN:
if (!gate) { // they moved away - re-arm
en_state = EN_ARMED;
en_t0 = millis();
} else if (millis() - en_t0 > 700) {
en_t0 = millis();
if (--en_count <= 0) {
en_state = EN_CAPTURE;
en_count = 0;
en_last_sample = 0;
} else {
beepTick();
}
}
break;
case EN_CAPTURE:
if (gate && millis() - en_last_sample > 400) {
en_last_sample = millis();
dl::detect::result_t &best = results.front();
recognizer.enroll_id((uint16_t *)fb->buf,
{(int)fb->height, (int)fb->width, 3},
best.keypoint, "", false /* RAM only */);
if (ids_used < MAX_IDS) id2slot[ids_used++] = en_slot;
en_count++;
Serial.printf("sample %d/%d for \"%s\"\n", en_count, SAMPLES_PER_FACE, names[en_slot]);
if (en_count >= SAMPLES_PER_FACE) {
samples_of[en_slot] = en_count;
en_state = EN_DONE;
en_t0 = millis();
beepEnrolled();
Serial.printf("\"%s\" enrolled.\n", names[en_slot]);
}
} else if (!gate && millis() - en_last_sample > 2500) {
en_state = EN_ARMED; // lost them mid-capture
en_t0 = millis();
}
break;
case EN_DONE:
if (millis() - en_t0 > 1500) en_state = EN_IDLE;
break;
}
/* ---- beep on arrival + action pin ---- */
{
static int prev_faces = 0;
static uint32_t last_beep = 0;
/* !known_face: a recognized person already got the triple chirp above -
* without this they would hear three chirps AND this single beep. */
if (faces > 0 && prev_faces == 0 && en_state == EN_IDLE && !known_face &&
millis() - last_beep > 2000) {
last_beep = millis();
beepSeen();
}
prev_faces = faces;
#if ACTION_PIN >= 0
digitalWrite(ACTION_PIN, known_face ? HIGH : LOW);
#endif
}
/* ---- draw ----
* The frame starts at VIEW_TOP so it never covers the status strip. During
* the "enrolled" confirmation the picture is frozen, which makes that
* banner rock solid instead of fighting the camera for the screen. */
if (en_state != EN_DONE) {
gfx->draw16bitBeRGBBitmap(0, VIEW_TOP,
(uint16_t *)fb->buf + VIEW_TOP * fb->width,
fb->width, fb->height - VIEW_TOP);
}
esp_camera_fb_return(fb);
if (en_state != EN_DONE) {
for (auto &r : results) {
int x1 = constrain(r.box[0], 0, 239), y1 = constrain(r.box[1], VIEW_TOP + 1, 239);
int x2 = constrain(r.box[2], 0, 239), y2 = constrain(r.box[3], VIEW_TOP + 1, 239);
uint16_t bc = known_face ? GREEN : (ids_used > 0 ? YELLOW : GREEN);
gfx->drawRect(x1, y1, x2 - x1, y2 - y1, bc);
gfx->drawRect(x1 + 1, y1 + 1, x2 - x1 - 2, y2 - y1 - 2, bc);
if (r.keypoint.size() >= 10)
for (int k = 0; k < 5; k++)
gfx->fillCircle(constrain(r.keypoint[k * 2], 0, 239),
constrain(r.keypoint[k * 2 + 1], VIEW_TOP + 2, 239), 2, RED);
}
}
/* state overlays */
char msg[48];
switch (en_state) {
case EN_ARMED:
// guide box the face has to fill
gfx->drawRect(60, 50, 120, 140, CYAN);
snprintf(msg, sizeof(msg), "Enroll: %s - step close", names[en_slot]);
overlay(msg, CYAN);
break;
case EN_COUNTDOWN:
snprintf(msg, sizeof(msg), "Enroll: %s - hold still... %d", names[en_slot], en_count);
overlay(msg, YELLOW);
gfx->setTextSize(5);
gfx->setTextColor(YELLOW);
gfx->setCursor(105, 95);
gfx->printf("%d", en_count);
gfx->setTextSize(1);
break;
case EN_CAPTURE:
snprintf(msg, sizeof(msg), "capturing %s %d/%d", names[en_slot], en_count, SAMPLES_PER_FACE);
overlay(msg, GREEN);
break;
case EN_DONE:
gfx->fillRect(30, 100, 180, 40, gfx->color565(0, 110, 0));
gfx->drawRect(30, 100, 180, 40, WHITE);
gfx->setTextSize(2);
gfx->setTextColor(WHITE);
gfx->setCursor(40, 112);
gfx->printf("%s OK", names[en_slot]);
gfx->setTextSize(1);
break;
default:
overlay("", CYAN); // idle: clear the strip (once, not per frame)
break;
}
/* Known faces get the big banner; unknown/forgotten keep the small strip.
* Suppressed during enrollment so it cannot collide with the guide box,
* the countdown digit or the "enrolled" confirmation. */
if (en_state == EN_IDLE) {
if (greet_name[0] && millis() < greet_until) drawGreeting();
else if (who[0] && !known_face) bottomBar(who, YELLOW);
}
/* Now that the name is on screen, greet them out loud. Falls back to the
* triple chirp when there is no SD card or no clip for that slot. */
if (pending_welcome >= 0) {
if (!playWelcome(pending_welcome)) beepRecognized();
pending_welcome = -1;
}
/* ---- taps ---- */
uint16_t tx, ty;
if (touchTapped(&tx, &ty)) {
if (tx >= BTN_X && ty >= BTN_CLEAR_Y && ty < BTN_CLEAR_Y + BTN_H) {
/* CLEAR: drop the name banner and cancel any enrollment in progress.
* The camera repaints the picture area, so we only need to clear the
* reserved strip explicitly. */
greet_name[0] = 0;
greet_until = 0;
pending_welcome = -1;
if (en_state != EN_IDLE) {
en_state = EN_IDLE;
Serial.println(F("enrollment cancelled (CLEAR)"));
}
overlay("", CYAN);
drawPanel(0, 0);
} else if (tx >= BTN_X && ty >= BTN_ENROLL_Y && ty < BTN_ENROLL_Y + BTN_H) { // ENROLL / NEXT
if (en_state == EN_IDLE) {
int s = nextNamedSlot(0);
if (s < 0) {
Serial.println(F("Roster empty - set a name first: name 0 YourName"));
} else {
en_slot = s;
en_state = EN_ARMED;
en_t0 = millis();
}
} else {
int s = nextNamedSlot(en_slot + 1); // cycle / cancel
if (s < 0) {
en_state = EN_IDLE;
Serial.println(F("enrollment cancelled"));
} else {
en_slot = s;
en_state = EN_ARMED;
en_t0 = millis();
}
}
}
}
/* ---- panel refresh ---- */
static uint32_t last_panel = 0;
uint32_t dt = millis() - t0;
if (dt < 1) dt = 1;
if (millis() - last_panel > 500) {
last_panel = millis();
drawPanel(faces, 1000.0f / dt);
}
}
Coisas que você pode precisar
-
Amazonas
-
OutroProduct page for MaTouch AI ESP32S3 2.8" TFT ST7789Vmakerfabs.com
Recursos e referências
-
DocumentaçãoMakerfabs MaTouch ESP32-S3 2.8" Camera and Touchscreen: User's Manualwiki.makerfabs.com
-
Documentação
-
DocumentaçãoProduct page for MaTouch AI ESP32S3 2.8" TFT ST7789Vmakerfabs.com
-
BaixarArduino GFX Library on GitHubgithub.com
Arquivos📁
Arquivo Obrigatório (.h)
Outros Ficheiros
Esquemático
-
MaTouch_AI 2.8" MaTouch AI ESP32S3 2.8" TFT ST7789V esquemáticoA placa MaTouch AI mais recente integra entrada de voz I2S / alto-falante I2S / câmera de 3 milhões OV3660 / display de resolução 320*240, com o forte processador ESP32S3 e capacidade Wi-Fi, para tornar esta placa uma boa ferramenta/plataforma para desenvolvimento de IA com ESP32.
MaTouch_AI 2.8“ SPI TFT ST7789V V1.1.PDF0.15 MB