Search Code

Makerfabs MaTouch ESP32-S3 2.8인치 카메라 오프라인 얼굴 감지 - 인터넷 없음, API 키 없음

Makerfabs MaTouch ESP32-S3 2.8인치 카메라 오프라인 얼굴 감지 - 인터넷 없음, API 키 없음


칩에서 실행되는 신경망 - 인터넷 없음, 계정 없음, 구독 없음

ESP32-S3에서 완전히 실행되는 얼굴 감지. 얼굴 주변의 녹색 상자, 눈·코·입의 빨간 점, 실시간 프레임 속도 카운터. 라우터를 뽑아도 계속 작동합니다. 신경망이 칩 자체 플래시에 있고 벡터 명령어로 실행되기 때문입니다.

여기에는 클라우드 서비스도, API 키도, 가입할 것도 없습니다. 어떤 것도 방 밖으로 나가지 않습니다.

Offline Face Detection running on the MaTouch AI ESP32-S3 board MaTouch AI ESP32-S3 보드에서 실행되는 오프라인 얼굴 감지

작동 방식

이것은 Espressif의 esp-dl 모델을 사용합니다: human_face_detect_msr01이 후보 얼굴을 제안하고 human_face_detect_mnp01이 이를 확인하고 다섯 개의 얼굴 키포인트를 찾는 2단계 검출기입니다. 두 모델 모두 ESP32 Arduino 코어 2.x 내부에 포함되어 있어 설치할 라이브러리가 없습니다.

이것이 이 사이트의 모든 프로젝트가 ESP32 코어 2.0.17을 고정하는 이유입니다. 얼굴 모델은 코어 3.x에서 제거되었으며, 이 스케치는 거기서 컴파일되지 않습니다 - human_face_detect_msr01.hpp: No such file or directory 오류가 발생합니다.

사운드 및 출력 핀

보드는 얼굴이 나타나면 스피커를 통해 짧은 소리를 내고, 얼굴이 프레임 안에 있는 동안 IO15를 HIGH로 구동합니다 - 릴레이, 램프 또는 잠금장치를 트리거할 수 있습니다. 스케치 상단에서 ENABLE_BEEP 또는 ACTION_PIN을 설정하여 둘 중 하나를 끌 수 있습니다.

카메라와 터치 패널이 버스를 공유합니다

카메라의 제어 인터페이스는 터치 패널, 클록 및 배터리 게이지와 동일한 두 개의 I2C 와이어를 사용합니다. 기본적으로 카메라 드라이버는 해당 핀에 자체 I2C 드라이버를 설치하고 터치 패널은 응답을 멈춥니다. 해결책은 카메라 구성에서 세 줄입니다:

config.pin_sccb_sda = -1;   // Wire가 이미 구동하는 버스 공유
config.pin_sccb_scl = -1;
config.sccb_i2c_port = 0;   // Wire는 I2C 포트 0

esp_camera_init() 전에 Wire.begin(39, 38)을 호출합니다. 이 보드에 대해 공개된 예제 중 어느 것도 이 작업을 수행하지 않습니다. 카메라를 시작한 후 터치 패널을 읽는 예제가 없기 때문입니다.

솔직한 한계

  • 검출기는 240×240 프레임에서 실행되므로 멀리 있는 작은 얼굴은 놓칩니다.

  • 검출은 인식이 아닙니다 - 이 스케치는 얼굴을 찾지만 누구의 얼굴인지는 알지 못합니다. 이에 대해서는 프로젝트 03b를 참조하세요.

  • 안티스푸핑이 없습니다. 얼굴 사진도 여전히 얼굴입니다.

MaTouch AI ESP32-S3 2.8" 보드 정보

이 페이지의 모든 프로젝트는 Makerfabs의 MaTouch AI ESP32-S3 2.8" TFT ST7789V에서 실행됩니다. 올인원 보드입니다: 컬러 터치스크린, 3메가픽셀 카메라, 두 개의 마이크와 실제 스피커 증폭기, 모두 8MB PSRAM이 있는 ESP32-S3로 구동됩니다. 이 조합이 다른 것을 연결하지 않고 단일 보드에서 이러한 AI 프로젝트를 가능하게 만듭니다.

8MB PSRAM은 여기서 다른 어떤 숫자보다 중요합니다. 이는 보드가 카메라 프레임, 몇 초의 녹음된 오디오 또는 base64로 인코딩된 사진을 동시에 메모리에 보관할 수 있게 해줍니다 - 이 중 어느 것도 ESP32의 일반 RAM에는 맞지 않습니다.

제조업체 문서: Makerfabs 위키 페이지.

주요 사양

  • 프로세서: ESP32-S3, 듀얼 코어 240MHz, WiFi 2.4GHz + Bluetooth 5.0

  • 메모리: 16MB 플래시, 8MB PSRAM (여기 거의 모든 프로젝트에 필요)

  • 디스플레이: 2.8" IPS, 320×240, ST7789V 드라이버, SPI

  • 터치: GT911 정전식, 동시에 5개 손가락 추적

  • 카메라: OV3660, 3메가픽셀, 최대 2048×1536

  • 마이크: INMP441 I2S 디지털 마이크 2개 (진정한 스테레오 쌍)

  • 스피커: MAX98357A 클래스 D 증폭기, 4Ω에서 3.2W

  • 저장소: microSD 카드 슬롯 (SPI 모드)

  • 전원: USB-C, JST 배터리 커넥터, TP4056 충전기, 전원 스위치

  • 추가 탑재: WS2812B RGB LED, PCF8563T 배터리 백업 실시간 클록, 공식 사양에 없는 MAX17048 배터리 연료 게이지

두 USB-C 포트는 동일하지 않습니다. 보드의 스피커는 신호 핀(IO19 및 IO20)을 네이티브 USB 포트와 공유합니다. 해당 핀이 ESP32-S3의 하드와이어링된 USB 데이터 라인이기 때문입니다. 항상 CH340K USB-C 포트(RESET 버튼 옆에 있는 포트)를 통해 업로드하고 전원을 공급하며, USB CDC On BootDisabled로 설정하세요. 잘못된 포트를 사용하면 오디오가 오작동하거나 업로드가 실패합니다.

Arduino IDE 설정

이 설정은 중요합니다. 사람들이 이 보드에서 보고하는 대부분의 문제는 이 중 하나가 잘못된 것이며, 코어 버전을 변경하면 재설정되므로 변경 후 다시 확인하세요.

설정

보드

ESP32S3 Dev Module

ESP32 코어 버전

2.0.17

PSRAM

OPI PSRAM

플래시 크기

16MB (128Mb)

파티션 구성

16M Flash (3MB APP/9.9MB FATFS)

부팅 시 USB CDC

비활성화

업로드 속도

921600

업로드 전 플래시 전체 지우기

비활성화

포트

CH340K USB-C 포트

ESP32 코어 2.0.17을 사용하세요. 3.x는 사용하지 마세요. Espressif는 코어 3에서 기기 내 얼굴 감지 모델을 제거했으므로, 얼굴 프로젝트는 해당 버전에서 컴파일되지 않습니다. 2.0.17로 고정하면 이 페이지의 모든 프로젝트가 하나의 구성으로 작동합니다. Boards Manager에서 버전 드롭다운을 사용하여 언제든지 전환할 수 있습니다.

GFX Library for Arduino 버전 1.5.6을 사용하세요. 1.6.x는 사용하지 마세요. 1.6 릴리스는 ESP32 코어 3용으로 제작되었으며 코어 2.0.17에서 시작 시 멈출 수 있습니다. 업로드 후 화면이 검게 유지되면 이것이 가장 먼저 확인할 사항입니다.

필수 라이브러리

Arduino IDE의 도구 → 라이브러리 관리를 통해 설치하세요. 버전 번호가 중요합니다 - 나열된 버전을 사용하세요.

라이브러리

버전

작성자

GFX Library for Arduino

1.5.6

moononournation

bb_captouch

1.3.1

Larry Bank

얼굴 감지 모델은 라이브러리가 아닙니다 - ESP32 코어 2.0.17 자체에 포함되어 있으므로 추가로 설치할 것이 없습니다.

문제 해결

증상

원인 및 해결 방법

화면이 검게 유지됨

잘못된 GFX 라이브러리 버전(1.5.6 사용) 또는 잘못된 보드 설정.

PSRAM 할당 실패 또는 카메라 오류 0xffffffff

도구 → PSRAMOPI PSRAM으로 설정되지 않았습니다.

업로드되지 않음 / COM 포트 없음

잘못된 USB-C 포트 또는 CH340 드라이버가 설치되지 않았습니다.

카메라가 실패하고 복구되지 않음

카메라의 리셋 라인이 보드의 RESET 버튼에 연결되어 있어 소프트웨어로 재시작할 수 없습니다. RESET을 누르세요. 그래도 실패하면 카메라 리본 케이블을 다시 장착하세요.

코드 다운로드

이 프로젝트의 완전한 Arduino 스케치와 pins.h 및 필요한 모든 파일은 무료로 다운로드할 수 있습니다.

03_Face_Offline 다운로드

압축을 풀고 Arduino IDE에서 .ino 파일을 열고 위 설정을 확인한 후 CH340K USB-C 포트를 통해 업로드하세요.


이것들은 제휴 링크입니다. 추가 비용이 들지 않으며 이 사이트의 무료 코드와 튜토리얼을 지원하는 데 도움이 됩니다 - 감사합니다.

883-Arduin code for MaTouch AI ESP32S3 2.8in AI Camera: offline face recognition
언어: C++
/*
 * ===========================================================================
 *  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);
  }
}

파일📁

필수 파일 (.h)

  • secrets.h
    file for Makerfabs MaTouch AI ESP32S3 2.8" TFT Camera module
    secrets.h 0.01 MB

기타 파일

  • pins.h
    pins file for MaTouch AI ESP32S3 2.8" camera LCD touch screen.
    pins.h 0.01 MB

도면

  • MaTouch_AI 2.8“ MaTouch AI ESP32S3 2.8" TFT ST7789V schematic
    The latest MaTouch AI board integrate I2S voice input/I2S speaker/ 3 million camera OV3660/ 320*240 resolution display, with ESP32S3 strong processor& Wifi ability, to make this board a good tool/platform for AI development with ESP32.
    MaTouch_AI 2.8“ SPI TFT ST7789V V1.1.PDF 0.15 MB