2201 lines
92 KiB
Python
2201 lines
92 KiB
Python
# ====================================================================================
|
||
# K210 V11 (分时网络通信)
|
||
# 本文件运行在 Sipeed Maix/K210 的 MaixPy 环境中,是识别端的主程序。
|
||
# 主要职责:
|
||
# 1. 通过摄像头采集画面,调用 KPU 模型完成人脸检测、关键点定位和特征提取;
|
||
# 2. 将识别结果通过 UART1 发送给 STM32,供 STM32 执行考勤判断、OLED 显示和蜂鸣器提示;
|
||
# 3. 通过 UART2 控制 ESP8266 连接服务端,上传新增人脸照片,同步学号映射和远程删除任务;
|
||
# 4. 将人脸特征、照片、face_id 与 student_id 映射持久化到 SD 卡,保证断电后仍可恢复。
|
||
# 设计说明:
|
||
# - K210 的主任务是实时识别,因此网络通信不能长时间占用主循环;
|
||
# - 网络相关操作采用“分时处理”:每隔若干帧只处理一个小任务,尽量避免画面卡顿;
|
||
# - 新增人脸时先写入本地 SD 卡,再排队上传,防止网络异常导致照片或特征丢失。
|
||
# ====================================================================================
|
||
|
||
import sensor, image, lcd
|
||
import KPU as kpu
|
||
import time
|
||
from Maix import FPIOA, GPIO, utils
|
||
import gc
|
||
from fpioa_manager import fm
|
||
import utime
|
||
from machine import UART
|
||
import os
|
||
try:
|
||
import ubinascii
|
||
except:
|
||
ubinascii = None
|
||
try:
|
||
import ucryptolib as crypto_lib
|
||
except:
|
||
try:
|
||
import cryptolib as crypto_lib
|
||
except:
|
||
crypto_lib = None
|
||
|
||
# --- 增加堆内存大小 ---
|
||
# K210 有 8MB SRAM,但 MaixPy 默认堆空间偏小。
|
||
# 人脸照片压缩为 JPEG、HTTP 拼包、Base64/AES 加解密都会临时申请较大的内存,
|
||
# 因此启动时把 GC 堆扩大到 1MB,减少运行过程中因内存不足导致的压缩或网络失败。
|
||
utils.gc_heap_size(1024 * 1024)
|
||
gc.collect()
|
||
print("[BOOT] Heap size set to 1MB")
|
||
|
||
# --- 配置参数(宏定义)---
|
||
SYNC_CHECK_INTERVAL_MS = 50000 # 检查服务端同步请求的时间间隔(毫秒),避免频繁访问接口
|
||
NETWORK_FRAME_INTERVAL = 300 # 每 N 帧检查一次网络任务;数值越小同步越及时,但识别画面越容易卡顿
|
||
|
||
# --- 分时网络任务队列 ---
|
||
photo_upload_queue = [] # 照片上传队列,保存待上传的 face_id;真正的 JPEG 数据存放在 SD 卡
|
||
network_task_pending = False # 预留的网络任务状态位,目前主要由照片队列和同步标志驱动
|
||
frames_since_network = 0 # 距离上次网络操作已经经过的帧数,用于在主循环中做分时调度
|
||
|
||
# --- 0. ESP8266 WiFi配置 ---
|
||
# 这些参数需要与实际部署环境保持一致:
|
||
# - WiFi 名称和密码决定 ESP8266 能否联网;
|
||
# - SERVER_IP/SERVER_PORT 指向考勤服务端;
|
||
# - API_TOKEN 必须与服务端 ATTENDANCE_API_TOKEN 一致,否则接口会拒绝请求。
|
||
WIFI_SSID = "yzfh" # WiFi 名称
|
||
WIFI_PASSWORD = "yzfhroot" # WiFi 密码
|
||
SERVER_IP = "27.106.113.205" # 服务端 IP 地址
|
||
SERVER_PORT = 8082 # 服务端 HTTP 端口
|
||
API_TOKEN = "atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV" # 接口访问令牌,和服务端配置保持一致
|
||
ESP_DEBUG = True # 是否打印 ESP8266 AT 响应,调试连接问题时建议开启
|
||
ESP_BAUD_RATES = (115200, 9600, 74880, 57600, 38400) # 常见 AT 固件波特率,启动时会自动探测
|
||
ESP_UART_BAUD = 115200 # 当前使用的 ESP8266 串口波特率
|
||
ESP_BOOT_WAIT_MS = 6000 # 等待 ESP8266 上电启动和自动联网的时间
|
||
LOCAL_FACE_PHOTO_DIR = "/sd/face_photos" # 本地人脸照片缓存目录,照片上传失败时用于后续补传
|
||
|
||
# AES-CBC 加密参数。
|
||
# K210 与服务端之间的 JSON 请求和响应使用 AES128-CBC + Base64 包装,
|
||
# 这样可以避免学号映射、删除任务等管理数据以明文形式在 HTTP 中传输。
|
||
AES_KEY = b"\x2b\x7e\x15\x16\x28\xae\xd2\xa6\xab\xf7\x15\x88\x09\xcf\x4f\x3c"
|
||
AES_IV = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"
|
||
|
||
# 函数作用:判断当前固件环境是否具备 AES 和 Base64 加解密能力。
|
||
def crypto_ready():
|
||
# MaixPy 固件版本不同,可用的加密模块名称也不同。
|
||
# 启动阶段已经兼容 ucryptolib/cryptolib,这里统一判断依赖是否齐全。
|
||
return crypto_lib is not None and ubinascii is not None
|
||
|
||
# 函数作用:为 AES-CBC 明文追加 PKCS#7 填充。
|
||
def pkcs7_pad(data):
|
||
# AES-CBC 要求输入长度必须是 16 字节的整数倍,
|
||
# PKCS#7 会在尾部补 N 个值为 N 的字节,服务端解密时按同一规则移除。
|
||
pad_len = 16 - (len(data) % 16)
|
||
return data + bytes([pad_len]) * pad_len
|
||
|
||
# 函数作用:移除 AES-CBC 解密结果中的 PKCS#7 填充。
|
||
def pkcs7_unpad(data):
|
||
# 解密后移除 PKCS#7 填充。
|
||
# 如果填充不合法,直接返回原始数据,避免因为异常数据导致主循环崩溃。
|
||
if not data:
|
||
return b""
|
||
pad_len = data[-1]
|
||
if pad_len < 1 or pad_len > 16 or pad_len > len(data):
|
||
return data
|
||
for b in data[-pad_len:]:
|
||
if b != pad_len:
|
||
return data
|
||
return data[:-pad_len]
|
||
|
||
# 函数作用:根据 encrypt 参数执行 AES-CBC 加密或解密。
|
||
def aes_cbc_crypt(data, encrypt=True):
|
||
# encrypt=True 表示加密,False 表示解密。
|
||
# MaixPy 的 aes(mode=2) 对应 CBC 模式,密钥和 IV 必须与服务端保持一致。
|
||
if not crypto_ready():
|
||
print("[CRYPTO] ucryptolib/ubinascii unavailable, refuse plaintext HTTP")
|
||
return None
|
||
try:
|
||
cipher = crypto_lib.aes(AES_KEY, 2, AES_IV)
|
||
return cipher.encrypt(data) if encrypt else cipher.decrypt(data)
|
||
except Exception as e:
|
||
print("[CRYPTO] AES failed:", e)
|
||
return None
|
||
|
||
# 函数作用:加密载荷并转换为 Base64 文本。
|
||
def encrypt_payload_base64(data):
|
||
# JSON 文本先转为 UTF-8 字节,再进行 PKCS#7 填充和 AES 加密,
|
||
# 最后用 Base64 转为可放进 HTTP body 的 ASCII 文本。
|
||
if isinstance(data, str):
|
||
data = data.encode("utf-8")
|
||
encrypted = aes_cbc_crypt(pkcs7_pad(data), True)
|
||
if encrypted is None:
|
||
return None
|
||
encoded = ubinascii.b2a_base64(encrypted).strip()
|
||
try:
|
||
return encoded.decode("utf-8")
|
||
except:
|
||
return str(encoded, "utf-8")
|
||
|
||
# 函数作用:把 Base64 密文解码并解密为明文。
|
||
def decrypt_payload_base64(data):
|
||
# 服务端返回的加密 body 是 Base64 字符串。
|
||
# 解密成功后返回 UTF-8 JSON 文本,失败时返回 None 由调用方决定是否重试。
|
||
if data is None:
|
||
return None
|
||
if isinstance(data, str):
|
||
data = data.strip().encode("utf-8")
|
||
try:
|
||
encrypted = ubinascii.a2b_base64(data)
|
||
except Exception as e:
|
||
print("[CRYPTO] Base64 decode failed:", e)
|
||
return None
|
||
decrypted = aes_cbc_crypt(encrypted, False)
|
||
if decrypted is None:
|
||
return None
|
||
try:
|
||
return pkcs7_unpad(decrypted).decode("utf-8")
|
||
except Exception as e:
|
||
print("[CRYPTO] UTF-8 decode failed:", e)
|
||
return None
|
||
|
||
# 函数作用:从 ESP8266 返回的 HTTP 响应中提取正文部分。
|
||
def extract_http_body(resp):
|
||
# ESP8266 透传回来的内容通常包含 AT 回显、HTTP 头、HTTP body、CLOSED/OK 等尾部文本。
|
||
# 这里按 HTTP 头结束符拆出 body,并尽量依据 Content-Length 截断,避免把 CLOSED 当作 JSON。
|
||
if not resp:
|
||
return ""
|
||
marker = "\r\n\r\n"
|
||
start = resp.find(marker)
|
||
if start == -1:
|
||
return ""
|
||
body = resp[start + len(marker):]
|
||
content_length = -1
|
||
cl_pos = resp.find("Content-Length:")
|
||
if cl_pos != -1:
|
||
cl_pos += len("Content-Length:")
|
||
while cl_pos < len(resp) and resp[cl_pos] == " ":
|
||
cl_pos += 1
|
||
cl_end = cl_pos
|
||
while cl_end < len(resp) and resp[cl_end].isdigit():
|
||
cl_end += 1
|
||
try:
|
||
content_length = int(resp[cl_pos:cl_end])
|
||
except:
|
||
content_length = -1
|
||
if content_length >= 0 and len(body) >= content_length:
|
||
return body[:content_length]
|
||
for marker in ("\r\nCLOSED", "CLOSED", "\r\nOK"):
|
||
pos = body.find(marker)
|
||
if pos != -1:
|
||
body = body[:pos]
|
||
return body.strip()
|
||
|
||
# 函数作用:判断 HTTP 响应正文是否已接收完整。
|
||
def http_body_complete(resp):
|
||
# 判断 HTTP 响应是否已经接收完整。
|
||
# 有 Content-Length 时以长度为准;没有长度时只能退化为等待连接关闭。
|
||
if not resp or "\r\n\r\n" not in resp:
|
||
return False
|
||
body = resp.split("\r\n\r\n", 1)[1]
|
||
cl_pos = resp.find("Content-Length:")
|
||
if cl_pos == -1:
|
||
return "CLOSED" in resp
|
||
cl_pos += len("Content-Length:")
|
||
while cl_pos < len(resp) and resp[cl_pos] == " ":
|
||
cl_pos += 1
|
||
cl_end = cl_pos
|
||
while cl_end < len(resp) and resp[cl_end].isdigit():
|
||
cl_end += 1
|
||
try:
|
||
return len(body) >= int(resp[cl_pos:cl_end])
|
||
except:
|
||
return "CLOSED" in resp
|
||
|
||
# 函数作用:提取并按需解密 HTTP JSON 响应正文。
|
||
def decode_http_json_body(resp):
|
||
# 统一处理服务端响应:
|
||
# - 带 X-Encrypted 响应头时按 AES 解密;
|
||
# - 明文 JSON 直接返回;
|
||
# - 其余情况尝试按加密 body 解析,兼容服务端响应头缺失的情况。
|
||
body = extract_http_body(resp)
|
||
if not body:
|
||
return None
|
||
if "X-Encrypted: AES128-CBC" in resp:
|
||
return decrypt_payload_base64(body)
|
||
if body.startswith("{") or body.startswith("["):
|
||
return body
|
||
return decrypt_payload_base64(body)
|
||
|
||
# 摄像头安装方向:当前实物画面上下左右都反,水平镜像+垂直翻转等价于旋转180度。
|
||
CAMERA_HMIRROR = 1
|
||
CAMERA_VFLIP = 1
|
||
|
||
# --- 1. UART通信初始化 ---
|
||
# UART1:连接 STM32。
|
||
# K210 识别出人脸后,会发送类似:
|
||
# ID:20210001,NAME:20210001,CONF:0.95
|
||
# 或:
|
||
# UNKNOWN
|
||
# STM32 端根据这条串口消息执行考勤记录、蜂鸣器和 OLED 显示逻辑。
|
||
fm.register(24, fm.fpioa.UART1_TX, force=True)
|
||
fm.register(25, fm.fpioa.UART1_RX, force=True)
|
||
uart = UART(UART.UART1, 115200, 8, 0, 1, timeout=1000, read_buf_len=4096)
|
||
|
||
# UART2:连接 ESP8266。
|
||
# 该串口只负责网络通信:WiFi 连接、HTTP GET/POST、照片上传和同步请求。
|
||
# read_buf_len 设置为 4096 是为了尽量一次容纳较长的 HTTP 响应片段。
|
||
fm.register(34, fm.fpioa.UART2_TX, force=True)
|
||
fm.register(35, fm.fpioa.UART2_RX, force=True)
|
||
uart2 = UART(UART.UART2, 115200, 8, 0, 1, timeout=2000, read_buf_len=4096)
|
||
|
||
# --- ESP8266 WiFi通信函数(优化版 - 减少阻塞)---
|
||
# 函数作用:按指定波特率重新打开 ESP8266 串口。
|
||
def esp_reopen_uart(baud):
|
||
"""按指定波特率重新打开ESP8266串口"""
|
||
global uart2, ESP_UART_BAUD
|
||
ESP_UART_BAUD = baud
|
||
try:
|
||
uart2.deinit()
|
||
except:
|
||
pass
|
||
uart2 = UART(UART.UART2, baud, 8, 0, 1, timeout=2000, read_buf_len=4096)
|
||
utime.sleep_ms(100)
|
||
|
||
# 函数作用:清空 ESP8266 串口接收缓冲区。
|
||
def esp_clear_rx():
|
||
"""清空ESP8266串口残留数据"""
|
||
# AT 固件经常会在命令之外输出 ready、WIFI GOT IP、CLOSED 等异步文本。
|
||
# 每次发送新命令前先清空缓冲区,能减少上一条响应污染本次解析的概率。
|
||
while uart2.any():
|
||
uart2.read()
|
||
utime.sleep_ms(10)
|
||
|
||
# 函数作用:打印 ESP8266 指令响应日志,便于串口调试。
|
||
def esp_log_response(cmd, response):
|
||
# 调试日志只截取前 180 个字符,避免长 HTTP 响应刷屏影响串口观察。
|
||
if ESP_DEBUG:
|
||
text = response.replace('\r\n', ' ').strip() if response else "(空)"
|
||
print("[ESP] {} => {}".format(cmd, text[:180]))
|
||
|
||
# 函数作用:向 ESP8266 发送 AT 指令并等待期望响应。
|
||
def esp_send_cmd(cmd, timeout=1000, expected=None):
|
||
"""发送AT命令到ESP8266并等待响应"""
|
||
esp_clear_rx()
|
||
uart2.write(cmd + "\r\n")
|
||
utime.sleep_ms(50)
|
||
start = utime.ticks_ms()
|
||
response = ""
|
||
if expected is None:
|
||
expected = ("OK", "ERROR", "FAIL", "ready")
|
||
# 轮询串口直到出现期望关键字或超时。
|
||
# 这里不用阻塞式长等待,是为了在网络异常时尽快回到识别主循环。
|
||
while utime.ticks_diff(utime.ticks_ms(), start) < timeout:
|
||
if uart2.any():
|
||
data = uart2.read()
|
||
if data:
|
||
response += data.decode('utf-8', 'ignore')
|
||
if any(token in response for token in expected):
|
||
break
|
||
utime.sleep_ms(5)
|
||
esp_log_response(cmd, response)
|
||
return response
|
||
|
||
# 函数作用:探测 ESP8266 串口是否能正常响应 AT 指令。
|
||
def esp_probe_uart():
|
||
"""探测ESP8266实际AT波特率"""
|
||
# 不同 ESP8266 AT 固件默认波特率可能不同;如果 115200 没有响应,
|
||
# 按常见波特率逐个尝试,找到能返回 OK 的配置后继续使用。
|
||
print("[ESP] Probing UART baud rate...")
|
||
for baud in ESP_BAUD_RATES:
|
||
print("[ESP] Try baud:", baud)
|
||
esp_reopen_uart(baud)
|
||
for _ in range(2):
|
||
resp = esp_send_cmd("AT", 1500)
|
||
if "OK" in resp:
|
||
print("[ESP] UART baud detected:", baud)
|
||
return True
|
||
utime.sleep_ms(300)
|
||
esp_reopen_uart(115200)
|
||
print("[ESP] No AT response on tested baud rates")
|
||
return False
|
||
|
||
# 函数作用:等待 ESP8266 上电启动完成。
|
||
def esp_wait_boot():
|
||
"""等待ESP8266启动完成,避免K210太早发送AT命令"""
|
||
print("[ESP] Waiting for module boot...")
|
||
start = utime.ticks_ms()
|
||
boot_log = ""
|
||
while utime.ticks_diff(utime.ticks_ms(), start) < ESP_BOOT_WAIT_MS:
|
||
if uart2.any():
|
||
data = uart2.read()
|
||
if data:
|
||
boot_log += data.decode('utf-8', 'ignore')
|
||
utime.sleep_ms(100)
|
||
if ESP_DEBUG and boot_log:
|
||
print("[ESP] Boot log:", boot_log.replace('\r\n', ' ')[:180])
|
||
esp_clear_rx()
|
||
|
||
# 函数作用:检查 ESP8266 当前 WiFi 连接状态。
|
||
def esp_check_wifi_status():
|
||
"""检查WiFi连接状态(快速检查)"""
|
||
# AT+CWJAP? 会返回当前连接的 AP 名称。
|
||
# 只做轻量检查,不重新连接,适合在上传照片前快速确认网络状态。
|
||
resp = esp_send_cmd("AT+CWJAP?", 1500)
|
||
return WIFI_SSID in resp
|
||
|
||
# 函数作用:控制 ESP8266 连接配置好的 WiFi 热点。
|
||
def esp_connect_wifi():
|
||
"""连接WiFi(单次尝试)"""
|
||
print("[ESP] Connecting to WiFi:", WIFI_SSID)
|
||
|
||
resp = esp_send_cmd("ATE0", 1000) # 关闭命令回显,后续解析响应更干净
|
||
|
||
# 使用 station 模式连接路由器。部分 AT 固件支持 _CUR 命令,部分只支持普通命令。
|
||
resp = esp_send_cmd("AT+CWMODE_CUR=1", 1500)
|
||
if "OK" not in resp:
|
||
resp = esp_send_cmd("AT+CWMODE=1", 1500)
|
||
|
||
if "OK" not in resp and "no change" not in resp:
|
||
print("[ESP] Set mode failed")
|
||
return False
|
||
|
||
if esp_check_wifi_status():
|
||
print("[ESP] WiFi already connected!")
|
||
return True
|
||
|
||
# 连接 WiFi。部分路由器 DHCP 分配 IP 较慢,等待时间不能太短。
|
||
# 先尝试 AT+CJAP_CUR,失败后再回退到 AT+CWJAP,兼容不同 AT 固件版本。
|
||
cmd = 'AT+CWJAP_CUR="{}","{}"'.format(WIFI_SSID, WIFI_PASSWORD)
|
||
resp = esp_send_cmd(cmd, 20000, ("WIFI GOT IP", "OK", "FAIL", "ERROR"))
|
||
|
||
if "OK" not in resp and "CONNECTED" not in resp and "WIFI GOT IP" not in resp:
|
||
cmd = 'AT+CWJAP="{}","{}"'.format(WIFI_SSID, WIFI_PASSWORD)
|
||
resp = esp_send_cmd(cmd, 20000, ("WIFI GOT IP", "OK", "FAIL", "ERROR"))
|
||
|
||
if "OK" in resp or "CONNECTED" in resp or "WIFI GOT IP" in resp:
|
||
utime.sleep_ms(1000)
|
||
status = esp_send_cmd("AT+CWJAP?", 1500)
|
||
ip = esp_send_cmd("AT+CIFSR", 1500)
|
||
if WIFI_SSID not in status and "STAIP" not in ip:
|
||
print("[ESP] WiFi command returned OK, but no station IP found")
|
||
return False
|
||
print("[ESP] WiFi connected!")
|
||
return True
|
||
|
||
print("[ESP] WiFi connect failed")
|
||
return False
|
||
|
||
# 函数作用:初始化 ESP8266 并尝试连接 WiFi。
|
||
def esp_init_wifi(max_retries=5):
|
||
"""初始化ESP8266并连接WiFi(减少重试次数)"""
|
||
print("[ESP] Initializing...")
|
||
esp_wait_boot()
|
||
esp_clear_rx()
|
||
|
||
# 测试 AT 响应。若默认波特率没有响应,再调用 esp_probe_uart 自动探测。
|
||
for i in range(max_retries):
|
||
resp = esp_send_cmd("AT", 2500)
|
||
if "OK" in resp:
|
||
print("[ESP] AT OK")
|
||
break
|
||
print("[ESP] AT retry {}/{}".format(i+1, max_retries))
|
||
utime.sleep_ms(500)
|
||
else:
|
||
if not esp_probe_uart():
|
||
print("[ESP] ESP8266 not responding!")
|
||
return False
|
||
|
||
# 连接 WiFi。初始化阶段允许多次重试,运行中重连时通常只传入 1 次,避免阻塞太久。
|
||
for i in range(max_retries):
|
||
if esp_connect_wifi():
|
||
return True
|
||
utime.sleep_ms(1000)
|
||
|
||
return False
|
||
|
||
# 函数作用:确保 WiFi 连接可用,断开时自动重连。
|
||
def esp_ensure_wifi():
|
||
"""确保WiFi已连接(快速检查)"""
|
||
global wifi_connected
|
||
if not wifi_connected:
|
||
wifi_connected = esp_init_wifi(1)
|
||
return wifi_connected
|
||
return wifi_connected
|
||
|
||
# 函数作用:确保本地人脸照片缓存目录存在。
|
||
def ensure_local_photo_dir():
|
||
"""确保SD卡本地照片目录存在"""
|
||
# MaixPy 的 os.mkdir 只能创建单级目录;这里目录路径固定为 /sd/face_photos,
|
||
# 启动或新增人脸时确认一次即可。
|
||
try:
|
||
os.listdir(LOCAL_FACE_PHOTO_DIR)
|
||
return True
|
||
except:
|
||
try:
|
||
os.mkdir(LOCAL_FACE_PHOTO_DIR)
|
||
print("[PHOTO] Created local dir:", LOCAL_FACE_PHOTO_DIR)
|
||
return True
|
||
except Exception as e:
|
||
print("[PHOTO] Create local dir failed:", e)
|
||
return False
|
||
|
||
# 函数作用:生成指定人脸编号的本地照片路径。
|
||
def local_face_photo_path(face_id):
|
||
# 使用 face_id 作为照片文件名,保证照片、特征和服务端档案可以稳定对应。
|
||
return "{}/{}.jpg".format(LOCAL_FACE_PHOTO_DIR, face_id)
|
||
|
||
# 函数作用:把采集到的人脸照片保存到本地 SD 卡。
|
||
def save_local_face_photo(face_id, jpeg_data):
|
||
"""先把人脸照片保存到SD卡,保证本地特征和照片一致"""
|
||
# 新增人脸时必须先保存照片再写入上传队列。
|
||
# 这样即使 ESP8266 掉线,下一次启动仍能扫描 SD 卡并补传这张照片。
|
||
if not ensure_local_photo_dir():
|
||
return False
|
||
path = local_face_photo_path(face_id)
|
||
try:
|
||
with open(path, "wb") as f:
|
||
f.write(jpeg_data)
|
||
print("[PHOTO] Saved local photo:", path)
|
||
return True
|
||
except Exception as e:
|
||
print("[PHOTO] Save local photo failed:", e)
|
||
return False
|
||
|
||
# 函数作用:读取指定人脸编号的本地照片数据。
|
||
def load_local_face_photo(face_id):
|
||
# 上传队列只保存 face_id,真正上传前再从 SD 卡读取 JPEG,减少常驻内存占用。
|
||
path = local_face_photo_path(face_id)
|
||
try:
|
||
with open(path, "rb") as f:
|
||
return f.read()
|
||
except Exception as e:
|
||
print("[PHOTO] Read local photo failed {}: {}".format(path, e))
|
||
return None
|
||
|
||
# 函数作用:从待上传照片队列中移除指定人脸编号。
|
||
def remove_photo_from_queue(face_id):
|
||
# 删除人脸或上传完成后,把对应 face_id 从待上传队列中移除,避免重复上传。
|
||
global photo_upload_queue
|
||
face_id = str(face_id)
|
||
photo_upload_queue = [x for x in photo_upload_queue if x != face_id]
|
||
|
||
# 函数作用:删除指定人脸编号的本地照片缓存。
|
||
def delete_local_face_photo(face_id):
|
||
# 本地照片与本地特征是一组数据;删除人脸时也删除照片,避免服务端之后补传无效照片。
|
||
face_id = str(face_id)
|
||
remove_photo_from_queue(face_id)
|
||
path = local_face_photo_path(face_id)
|
||
try:
|
||
os.remove(path)
|
||
print("[PHOTO] Deleted local photo:", path)
|
||
return True
|
||
except:
|
||
return False
|
||
|
||
# 函数作用:删除所有本地人脸照片缓存。
|
||
def delete_all_local_face_photos():
|
||
# 清空全部人脸时同步清理照片目录和上传队列,保持 SD 卡状态一致。
|
||
if not ensure_local_photo_dir():
|
||
return
|
||
try:
|
||
for name in os.listdir(LOCAL_FACE_PHOTO_DIR):
|
||
if name.endswith(".jpg"):
|
||
try:
|
||
os.remove("{}/{}".format(LOCAL_FACE_PHOTO_DIR, name))
|
||
except:
|
||
pass
|
||
photo_upload_queue[:] = []
|
||
print("[PHOTO] Deleted all local face photos")
|
||
except Exception as e:
|
||
print("[PHOTO] Delete all local photos failed:", e)
|
||
|
||
# 函数作用:通过 ESP8266 发送一段原始 TCP 数据。
|
||
def esp_send_data(data, timeout=1500):
|
||
"""发送数据并等待SEND OK(优化超时)"""
|
||
# ESP8266 AT 发送流程固定为:
|
||
# 1. AT+CIPSEND=<长度>
|
||
# 2. 等待 '>' 提示符
|
||
# 3. 写入数据
|
||
# 4. 等待 SEND OK
|
||
# 这里封装成一个函数,供 HTTP 头、JSON body、JPEG 分块共用。
|
||
cmd = "AT+CIPSEND={}".format(len(data))
|
||
uart2.write(cmd + "\r\n")
|
||
utime.sleep_ms(50)
|
||
|
||
# 等待 '>' 提示符。若没有提示符,说明 TCP 连接不可写,直接失败。
|
||
start = utime.ticks_ms()
|
||
resp = ""
|
||
got_prompt = False
|
||
while utime.ticks_diff(utime.ticks_ms(), start) < 1000:
|
||
if uart2.any():
|
||
resp += uart2.read().decode('utf-8', 'ignore')
|
||
if ">" in resp:
|
||
got_prompt = True
|
||
break
|
||
if "ERROR" in resp or "link is not" in resp:
|
||
return False
|
||
utime.sleep_ms(5)
|
||
|
||
if not got_prompt:
|
||
return False
|
||
|
||
# 发送实际数据。data 既可能是字符串,也可能是 JPEG bytes。
|
||
uart2.write(data)
|
||
|
||
# 等待 SEND OK。超时设置偏短,是为了网络异常时不长时间卡住识别。
|
||
start = utime.ticks_ms()
|
||
resp = ""
|
||
while utime.ticks_diff(utime.ticks_ms(), start) < timeout:
|
||
if uart2.any():
|
||
resp += uart2.read().decode('utf-8', 'ignore')
|
||
if "SEND OK" in resp:
|
||
return True
|
||
if "SEND FAIL" in resp or "ERROR" in resp:
|
||
return False
|
||
utime.sleep_ms(10)
|
||
|
||
return False
|
||
|
||
# 函数作用:通过 HTTP POST 上传指定人脸照片到服务端。
|
||
def esp_http_post_photo(face_id, jpeg_data, max_retries=2):
|
||
"""通过ESP8266发送HTTP POST请求上传照片"""
|
||
# 直接使用 face_id 作为照片 ID(不加时间戳),服务端可用该 ID 覆盖或更新同一张人脸照片。
|
||
print("[ESP] Uploading photo for face ID:", face_id, "size:", len(jpeg_data))
|
||
|
||
for attempt in range(max_retries):
|
||
# 上传前确认 WiFi 已连接;若未连接则尝试一次快速重连。
|
||
if not esp_ensure_wifi():
|
||
print("[ESP] No WiFi, retry {}/{}".format(attempt+1, max_retries))
|
||
utime.sleep_ms(2000)
|
||
continue
|
||
|
||
# 关闭之前残留的 TCP 连接,避免 CIPSEND 写入旧连接导致失败。
|
||
esp_send_cmd("AT+CIPCLOSE", 500)
|
||
utime.sleep_ms(100)
|
||
|
||
# 设置单连接模式,简化后续 CIPSEND 和关闭连接的处理。
|
||
esp_send_cmd("AT+CIPMUX=0", 500)
|
||
|
||
# 建立到服务端 HTTP 端口的 TCP 连接。
|
||
cmd = 'AT+CIPSTART="TCP","{}",{}'.format(SERVER_IP, SERVER_PORT)
|
||
resp = esp_send_cmd(cmd, 3000)
|
||
if "OK" not in resp and "CONNECT" not in resp:
|
||
print("[ESP] TCP failed, retry {}/{}".format(attempt+1, max_retries))
|
||
utime.sleep_ms(1000)
|
||
continue
|
||
|
||
print("[ESP] TCP connected, sending data...")
|
||
|
||
# 构造照片上传请求。
|
||
# 这里 Content-Type 使用 image/jpeg,body 直接放 JPEG bytes,不参与 AES 加密,
|
||
# 因为照片本身较大,K210 端加密会显著增加内存压力和上传时间。
|
||
http_header = "POST /api/v1/faces/photos/{} HTTP/1.1\r\n".format(face_id)
|
||
http_header += "Host: {}:{}\r\n".format(SERVER_IP, SERVER_PORT)
|
||
http_header += "Content-Type: image/jpeg\r\n"
|
||
http_header += "X-Api-Token: {}\r\n".format(API_TOKEN)
|
||
http_header += "Content-Length: {}\r\n".format(len(jpeg_data))
|
||
http_header += "Connection: close\r\n\r\n"
|
||
|
||
# 先发送 HTTP 头。照片 body 后续分块发送。
|
||
if not esp_send_data(http_header):
|
||
print("[ESP] Send header failed")
|
||
esp_send_cmd("AT+CIPCLOSE", 500)
|
||
continue
|
||
|
||
# 分块发送 JPEG 数据。
|
||
# ESP8266 AT 透传缓冲区有限,大照片一次性发送容易失败,按 1024 字节拆分更稳。
|
||
CHUNK_SIZE = 1024
|
||
offset = 0
|
||
send_ok = True
|
||
|
||
while offset < len(jpeg_data):
|
||
chunk = jpeg_data[offset:offset + CHUNK_SIZE]
|
||
if not esp_send_data(chunk):
|
||
print("[ESP] Send chunk failed at", offset)
|
||
send_ok = False
|
||
break
|
||
offset += CHUNK_SIZE
|
||
print("[ESP] Sent {}/{}".format(min(offset, len(jpeg_data)), len(jpeg_data)))
|
||
|
||
if not send_ok:
|
||
esp_send_cmd("AT+CIPCLOSE", 500)
|
||
utime.sleep_ms(500)
|
||
continue
|
||
|
||
# 等待服务器响应。这里只要数据全部 SEND OK,基本可认为上传成功;
|
||
# 响应读取主要用于调试和及时发现服务端拒绝。
|
||
print("[ESP] Waiting for response...")
|
||
resp = ""
|
||
start = utime.ticks_ms()
|
||
while utime.ticks_diff(utime.ticks_ms(), start) < 3000:
|
||
if uart2.any():
|
||
data = uart2.read()
|
||
if data:
|
||
resp += data.decode('utf-8', 'ignore')
|
||
if "200" in resp or "success" in resp or "HTTP/1.1" in resp:
|
||
print("[ESP] Got response!")
|
||
break
|
||
utime.sleep_ms(50)
|
||
|
||
esp_send_cmd("AT+CIPCLOSE", 300)
|
||
|
||
# 数据发送成功就算成功
|
||
if send_ok:
|
||
print("[ESP] Photo uploaded successfully!")
|
||
return True
|
||
else:
|
||
print("[ESP] Response:", resp[:100] if resp else "(empty)")
|
||
print("[ESP] Retry {}/{}".format(attempt+1, max_retries))
|
||
utime.sleep_ms(1000)
|
||
|
||
print("[ESP] Upload failed after {} retries".format(max_retries))
|
||
return False
|
||
|
||
# 函数作用:保存当前人脸照片并尝试上传到服务端。
|
||
def capture_and_upload_face(face_id, img):
|
||
"""拍摄人脸照片并加入上传队列(非阻塞)"""
|
||
try:
|
||
# 先复制图像,避免压缩、转换等操作影响当前 LCD 显示和后续识别处理。
|
||
img_copy = img.copy()
|
||
|
||
# 将当前画面压缩为 JPEG。
|
||
# quality=50 在清晰度和体积之间折中,适合 ESP8266 低速串口上传。
|
||
try:
|
||
jpeg_img = img_copy.compress(quality=50) # 降低质量减小体积
|
||
jpeg_data = jpeg_img.to_bytes()
|
||
except Exception as e1:
|
||
print("[PHOTO] Compress method 1 failed:", e1)
|
||
# 如果当前固件不支持 compress,则尝试直接取字节,保留兼容性。
|
||
try:
|
||
jpeg_data = img_copy.to_bytes() # 直接尝试转换
|
||
except Exception as e2:
|
||
print("[PHOTO] Compress method 2 failed:", e2)
|
||
return False
|
||
|
||
print("[PHOTO] JPEG size:", len(jpeg_data), "bytes")
|
||
|
||
if len(jpeg_data) < 100:
|
||
print("[PHOTO] JPEG too small, skip upload")
|
||
return False
|
||
|
||
# 先保存本地,再加入上传队列(非阻塞)。
|
||
# 注意:这里不直接调用 HTTP 上传,避免新增人脸时 UI 长时间卡住。
|
||
if not save_local_face_photo(face_id, jpeg_data):
|
||
return False
|
||
|
||
queue_photo_upload(face_id)
|
||
print("[PHOTO] Face {} saved locally and queued for upload".format(face_id))
|
||
return True
|
||
|
||
except Exception as e:
|
||
print("[PHOTO] Error:", e)
|
||
return False
|
||
|
||
# 初始化 WiFi 连接(启动时尝试连接)。
|
||
# 若启动时失败,识别功能仍然可用;照片会保存在 SD 卡,等后续重启或网络恢复后补传。
|
||
wifi_connected = False
|
||
print("[BOOT] Starting ESP8266 WiFi init...")
|
||
try:
|
||
wifi_connected = esp_init_wifi()
|
||
print("[BOOT] WiFi status:", "connected" if wifi_connected else "not connected")
|
||
except Exception as e:
|
||
print("[BOOT] ESP8266 init error:", e)
|
||
|
||
# --- 人脸ID到真实学号的映射表 ---
|
||
# K210 内部识别使用的是本地 face_id,例如 ID:1、ID:2。
|
||
# 管理端和考勤记录更关心真实学号,因此单独维护 face_id -> student_id 的映射。
|
||
# 这样可以做到:
|
||
# - recordftr.txt 只保存 K210 原始人脸特征和 face_id,不因学生信息变更而改动特征库;
|
||
# - 管理端可以远程调整学号绑定,K210 同步后立刻按新学号上报给 STM32;
|
||
# - 未绑定学号的人脸仍可使用 face_id 作为兜底身份,便于调试和后续补绑定。
|
||
face_id_mapping = {} # 格式: {"1": "20210001", "2": "20210002"}
|
||
MAPPING_FILE = "/sd/face_mapping.txt" # SD 卡上的映射文件,简单使用 key=value 文本格式
|
||
SYNC_VERSION_FILE = "/sd/sync_version.txt" # 本地已应用的服务端同步版本号
|
||
RECORD_PATH = "/sd/recordftr.txt" # 人脸特征记录文件路径
|
||
sync_last_version = 0 # K210 已处理到的服务端统一同步版本
|
||
|
||
# 函数作用:从本地文件加载人脸编号到学号的映射关系。
|
||
def load_local_mapping():
|
||
"""从SD卡加载本地映射配置"""
|
||
global face_id_mapping
|
||
face_id_mapping.clear()
|
||
try:
|
||
with open(MAPPING_FILE, "r") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if '=' in line:
|
||
# 每行格式为 face_id=student_id。
|
||
# 不使用 JSON 是为了降低 MaixPy 端解析复杂度,并方便现场手动查看。
|
||
parts = line.split('=')
|
||
if len(parts) == 2:
|
||
face_id_mapping[parts[0]] = parts[1]
|
||
print("[LOCAL] Loaded {} mappings from SD".format(len(face_id_mapping)))
|
||
except:
|
||
print("[LOCAL] No local mapping file")
|
||
|
||
# 函数作用:把当前人脸编号到学号映射保存到本地文件。
|
||
def save_local_mapping():
|
||
"""保存映射配置到SD卡"""
|
||
try:
|
||
with open(MAPPING_FILE, "w") as f:
|
||
for k, v in face_id_mapping.items():
|
||
f.write("{}={}\n".format(k, v))
|
||
print("[LOCAL] Saved {} mappings to SD".format(len(face_id_mapping)))
|
||
return True
|
||
except Exception as e:
|
||
print("[LOCAL] Save error:", e)
|
||
return False
|
||
|
||
# 函数作用:从本地文件读取最近处理过的服务端同步版本号。
|
||
def load_sync_version():
|
||
"""从SD卡加载本地已同步版本"""
|
||
# 服务端每次变更人脸档案或删除任务都会推进统一版本号。
|
||
# K210 保存最后一次成功应用的版本,下次只拉取增量状态,避免重复处理旧任务。
|
||
global sync_last_version
|
||
try:
|
||
with open(SYNC_VERSION_FILE, "r") as f:
|
||
value = f.read().strip()
|
||
sync_last_version = int(value) if value else 0
|
||
except:
|
||
sync_last_version = 0
|
||
print("[LOCAL] Sync version:", sync_last_version)
|
||
|
||
# 函数作用:保存最近处理过的服务端同步版本号。
|
||
def save_sync_version(version):
|
||
"""保存本地已同步版本"""
|
||
# 只有在映射应用、删除任务和 ACK 都成功后才推进版本号。
|
||
# 如果删除失败则保留旧版本,下一轮同步会继续重试。
|
||
global sync_last_version
|
||
sync_last_version = version
|
||
try:
|
||
with open(SYNC_VERSION_FILE, "w") as f:
|
||
f.write(str(version))
|
||
except Exception as e:
|
||
print("[LOCAL] Save sync version error:", e)
|
||
|
||
# 函数作用:更新本地人脸映射记录中的学生编号。
|
||
def update_face_record(face_id, new_student_id):
|
||
"""保存face_id到学号的映射,保持recordftr.txt中的原始face_id不变。"""
|
||
global face_id_mapping, peoples
|
||
|
||
face_id = str(face_id)
|
||
new_student_id = str(new_student_id)
|
||
# 服务端可能存在历史映射或照片占位,但 K210 本地没有对应特征。
|
||
# 这类映射不能直接保存,否则识别端会显示不存在的人脸档案。
|
||
if not local_face_id_exists(face_id):
|
||
print("[UPDATE] Ignore mapping for missing local face_id {} -> {}".format(face_id, new_student_id))
|
||
return False
|
||
|
||
face_id_mapping[face_id] = new_student_id
|
||
save_local_mapping()
|
||
|
||
# 只验证本地特征是否存在,不修改 recordftr.txt 中的人脸名称。
|
||
# 识别算法仍使用原始 ID:x,串口发送前再查映射转换成学号。
|
||
found = False
|
||
for p in peoples:
|
||
if p.name == "ID:{}".format(face_id) or p.name == face_id:
|
||
found = True
|
||
break
|
||
|
||
if found:
|
||
print("[UPDATE] Mapping saved: face_id {} -> student_id {}".format(face_id, new_student_id))
|
||
else:
|
||
print("[UPDATE] Mapping saved, local face feature not found yet: {}".format(face_id))
|
||
return True
|
||
|
||
# 函数作用:通过 ESP8266 发起 HTTP GET 请求并返回响应。
|
||
def esp_http_get(url_path, timeout=8000):
|
||
"""发送HTTP GET请求并返回响应内容"""
|
||
if not wifi_connected:
|
||
print("[HTTP_GET] WiFi未连接")
|
||
return None
|
||
|
||
print("[HTTP_GET] 关闭旧连接...")
|
||
esp_send_cmd("AT+CIPCLOSE", 500)
|
||
utime.sleep_ms(300)
|
||
esp_send_cmd("AT+CIPMUX=0", 500)
|
||
utime.sleep_ms(100)
|
||
|
||
# 建立TCP连接
|
||
print("[HTTP_GET] 建立TCP连接: {}:{}".format(SERVER_IP, SERVER_PORT))
|
||
cmd = 'AT+CIPSTART="TCP","{}",{}'.format(SERVER_IP, SERVER_PORT)
|
||
resp = esp_send_cmd(cmd, 5000)
|
||
print("[HTTP_GET] TCP响应: {}".format(resp.replace('\r\n', ' ')[:80] if resp else "(空)"))
|
||
|
||
if "OK" not in resp and "CONNECT" not in resp:
|
||
print("[HTTP_GET] TCP连接失败")
|
||
return None
|
||
|
||
print("[HTTP_GET] TCP连接成功")
|
||
utime.sleep_ms(200)
|
||
|
||
# 构建 HTTP GET 请求。
|
||
# X-Encrypted-Response 表示希望服务端返回 AES 加密后的 JSON,保护同步数据。
|
||
http_request = "GET {} HTTP/1.1\r\nHost: {}:{}\r\nX-Api-Token: {}\r\nX-Encrypted-Response: true\r\nConnection: close\r\n\r\n".format(
|
||
url_path, SERVER_IP, SERVER_PORT, API_TOKEN)
|
||
|
||
# 使用 AT+CIPSEND 发送请求头。GET 请求没有 body,所以一次发送即可。
|
||
print("[HTTP_GET] 准备发送 {} bytes".format(len(http_request)))
|
||
cmd = "AT+CIPSEND={}".format(len(http_request))
|
||
uart2.write(cmd + "\r\n")
|
||
utime.sleep_ms(100)
|
||
|
||
# 等待 '>' 提示符,说明 ESP8266 已准备接收待发送数据。
|
||
start = utime.ticks_ms()
|
||
prompt_resp = ""
|
||
while utime.ticks_diff(utime.ticks_ms(), start) < 2000:
|
||
if uart2.any():
|
||
data = uart2.read()
|
||
if data:
|
||
prompt_resp += data.decode('utf-8', 'ignore')
|
||
if ">" in prompt_resp:
|
||
break
|
||
utime.sleep_ms(10)
|
||
|
||
if ">" not in prompt_resp:
|
||
print("[HTTP_GET] 未收到>提示符: {}".format(prompt_resp[:50]))
|
||
esp_send_cmd("AT+CIPCLOSE", 300)
|
||
return None
|
||
|
||
print("[HTTP_GET] 收到>提示符,发送请求...")
|
||
|
||
# 发送 HTTP 请求文本。
|
||
uart2.write(http_request)
|
||
|
||
# 等待 SEND OK,确认 ESP8266 已把请求写入 TCP 连接。
|
||
start = utime.ticks_ms()
|
||
send_resp = ""
|
||
while utime.ticks_diff(utime.ticks_ms(), start) < 3000:
|
||
if uart2.any():
|
||
data = uart2.read()
|
||
if data:
|
||
send_resp += data.decode('utf-8', 'ignore')
|
||
if "SEND OK" in send_resp:
|
||
print("[HTTP_GET] SEND OK")
|
||
break
|
||
utime.sleep_ms(10)
|
||
|
||
if "SEND OK" not in send_resp:
|
||
print("[HTTP_GET] 发送失败: {}".format(send_resp[:50]))
|
||
esp_send_cmd("AT+CIPCLOSE", 300)
|
||
return None
|
||
|
||
# 等待服务器响应。
|
||
# 同步接口返回内容可能较长,所以不能只等第一次串口可读就结束。
|
||
print("[HTTP_GET] 等待服务器响应...")
|
||
resp = send_resp # 继续累积数据
|
||
start = utime.ticks_ms()
|
||
|
||
while utime.ticks_diff(utime.ticks_ms(), start) < timeout:
|
||
if uart2.any():
|
||
data = uart2.read()
|
||
if data:
|
||
try:
|
||
resp += data.decode('utf-8', 'ignore')
|
||
print("[HTTP_GET] 累积数据: {} bytes".format(len(resp)))
|
||
except:
|
||
pass
|
||
# 加密响应是Base64文本,按Content-Length或连接关闭判断完整性。
|
||
if http_body_complete(resp):
|
||
if "CLOSED" in resp:
|
||
print("[HTTP_GET] 连接已关闭,数据完整")
|
||
break
|
||
# 继续等一下看有没有更多数据
|
||
utime.sleep_ms(500)
|
||
if uart2.any():
|
||
extra = uart2.read()
|
||
if extra:
|
||
resp += extra.decode('utf-8', 'ignore')
|
||
if "CLOSED" in resp or utime.ticks_diff(utime.ticks_ms(), start) > 3000:
|
||
break
|
||
utime.sleep_ms(30)
|
||
|
||
esp_send_cmd("AT+CIPCLOSE", 300)
|
||
|
||
print("[HTTP_GET] 原始响应长度: {}".format(len(resp)))
|
||
if len(resp) > 0:
|
||
print("[HTTP_GET] 响应片段: {}".format(resp[:300].replace('\r\n', '\\n')))
|
||
|
||
result = decode_http_json_body(resp)
|
||
if result:
|
||
print("[HTTP_GET] 解密JSON成功: {}".format(result[:100]))
|
||
return result
|
||
|
||
print("[HTTP_GET] 未找到可用JSON")
|
||
return None
|
||
|
||
# 函数作用:从人脸名称中提取纯人脸编号。
|
||
def extract_face_id(face_name):
|
||
# recordftr.txt 中的人脸名称一般是 ID:数字。
|
||
# 这里统一剥离 ID: 前缀,后续映射、删除和照片文件名都使用纯数字 face_id。
|
||
face_name = str(face_name)
|
||
if face_name.startswith("ID:"):
|
||
return face_name.split(":", 1)[1]
|
||
return face_name
|
||
|
||
# 函数作用:判断本地人脸库中是否存在指定人脸编号。
|
||
def local_face_id_exists(face_id):
|
||
# 判断本地特征库中是否存在该 face_id,避免服务端旧数据覆盖本地状态。
|
||
face_id = str(face_id)
|
||
for p in peoples:
|
||
if extract_face_id(p.name) == face_id:
|
||
return True
|
||
return False
|
||
|
||
# 函数作用:根据人脸名称获取映射后的真实学号。
|
||
def get_mapped_student_id(face_name):
|
||
# 根据识别到的人脸名称查找真实学号。
|
||
# 如果没有绑定,返回空字符串,由调用方决定是否使用 face_id 兜底。
|
||
face_id = extract_face_id(face_name)
|
||
return face_id_mapping.get(face_id, "")
|
||
|
||
# 函数作用:获取用于屏幕显示的姓名或学号文本。
|
||
def get_display_name(face_name):
|
||
"""显示学号映射;未绑定时显示原始face_id。"""
|
||
mapped = get_mapped_student_id(face_name)
|
||
return mapped if mapped else face_name
|
||
|
||
# 加载本地映射配置和同步版本。
|
||
# 这一步必须在同步逻辑前执行,否则无法判断服务端状态是否比本地更新。
|
||
load_local_mapping()
|
||
load_sync_version()
|
||
|
||
# 启动时同步将在所有函数定义后执行(见文件末尾)
|
||
|
||
# --- 定时同步配置 ---
|
||
last_sync_time = utime.ticks_ms()
|
||
sync_in_progress = False # 预留同步状态标志,便于后续改成更细粒度的分步同步
|
||
sync_step = 0 # 预留同步步骤编号,当前版本的同步仍以一次接口调用为主
|
||
|
||
# --- 远程删除人脸功能 ---
|
||
# 函数作用:按人脸编号删除本地人脸记录及对应缓存。
|
||
def delete_face_by_id(face_id):
|
||
"""根据人脸ID删除本地人脸数据"""
|
||
global peoples
|
||
|
||
# 本地特征库以 ID:face_id 命名,因此远程删除任务只需要传入纯 face_id。
|
||
target_patterns = ["ID:{}".format(face_id)]
|
||
|
||
print("[DELETE] Looking for patterns:", target_patterns)
|
||
|
||
deleted_index = -1
|
||
for i, p in enumerate(peoples):
|
||
for pattern in target_patterns:
|
||
if p.name == pattern:
|
||
deleted_index = i
|
||
break
|
||
if deleted_index >= 0:
|
||
break
|
||
|
||
if deleted_index >= 0:
|
||
deleted_name = peoples[deleted_index].name
|
||
peoples.pop(deleted_index)
|
||
try:
|
||
# 删除内存中的特征后立刻重写 recordftr.txt,保证下次启动不会恢复已删除人脸。
|
||
write_face_record()
|
||
print("[DELETE] 删除人脸成功: {}".format(deleted_name))
|
||
|
||
# 同时删除本地照片,避免下次启动补传已删除的人脸照片
|
||
delete_local_face_photo(face_id)
|
||
if deleted_name.startswith("ID:"):
|
||
delete_local_face_photo(deleted_name.split(":", 1)[1])
|
||
else:
|
||
delete_local_face_photo(deleted_name)
|
||
for pattern in target_patterns:
|
||
if pattern.startswith("ID:"):
|
||
delete_local_face_photo(pattern.split(":", 1)[1])
|
||
else:
|
||
delete_local_face_photo(pattern)
|
||
|
||
# 同时删除学号映射,避免 UI 或串口发送仍显示已删除人脸的绑定关系。
|
||
if face_id in face_id_mapping:
|
||
del face_id_mapping[face_id]
|
||
save_local_mapping()
|
||
|
||
# 重新加载特征库,确认文件和内存状态一致。
|
||
load_faces()
|
||
return True
|
||
except Exception as e:
|
||
print("[DELETE] 重写文件失败:", e)
|
||
load_faces()
|
||
return False
|
||
else:
|
||
print("[DELETE] 未找到人脸ID: {}".format(face_id))
|
||
return False
|
||
|
||
# 函数作用:通过 ESP8266 发送加密 JSON POST 请求。
|
||
def esp_http_post_json(url_path, json_data, timeout=2000):
|
||
"""发送HTTP POST请求(优化版)"""
|
||
if not wifi_connected:
|
||
return None
|
||
# JSON 管理数据统一加密后再发送,HTTP body 使用 application/octet-stream。
|
||
encrypted_data = encrypt_payload_base64(json_data)
|
||
if encrypted_data is None:
|
||
return None
|
||
|
||
esp_send_cmd("AT+CIPCLOSE", 300)
|
||
utime.sleep_ms(50)
|
||
esp_send_cmd("AT+CIPMUX=0", 300)
|
||
|
||
cmd = 'AT+CIPSTART="TCP","{}",{}'.format(SERVER_IP, SERVER_PORT)
|
||
resp = esp_send_cmd(cmd, 2000)
|
||
if "OK" not in resp and "CONNECT" not in resp:
|
||
return None
|
||
|
||
# 请求头同时声明本次请求体已加密,并要求服务端响应也以加密形式返回。
|
||
http_request = "POST {} HTTP/1.1\r\nHost: {}:{}\r\nContent-Type: application/octet-stream\r\nX-Api-Token: {}\r\nX-Encrypted: AES128-CBC\r\nX-Encrypted-Response: true\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}".format(
|
||
url_path, SERVER_IP, SERVER_PORT, API_TOKEN, len(encrypted_data), encrypted_data)
|
||
|
||
if not esp_send_data(http_request, 1500):
|
||
esp_send_cmd("AT+CIPCLOSE", 300)
|
||
return None
|
||
|
||
resp = ""
|
||
start = utime.ticks_ms()
|
||
while utime.ticks_diff(utime.ticks_ms(), start) < timeout:
|
||
if uart2.any():
|
||
data = uart2.read()
|
||
if data:
|
||
resp += data.decode('utf-8', 'ignore')
|
||
if http_body_complete(resp):
|
||
break
|
||
utime.sleep_ms(10)
|
||
|
||
esp_send_cmd("AT+CIPCLOSE", 300)
|
||
return decode_http_json_body(resp)
|
||
|
||
# 函数作用:从简单 JSON 文本中读取整数字段。
|
||
def json_get_int(text, key, default=0):
|
||
# MaixPy 环境下完整 JSON 库可能不可用或占用内存较多。
|
||
# 服务端返回结构固定,因此用轻量字符串扫描提取简单整数字段。
|
||
marker = '"{}":'.format(key)
|
||
start = text.find(marker)
|
||
if start == -1:
|
||
return default
|
||
start += len(marker)
|
||
while start < len(text) and text[start] == ' ':
|
||
start += 1
|
||
end = start
|
||
while end < len(text) and text[end].isdigit():
|
||
end += 1
|
||
try:
|
||
return int(text[start:end])
|
||
except:
|
||
return default
|
||
|
||
# 函数作用:从 JSON 文本中截取指定字段对应的对象或数组片段。
|
||
def extract_json_block(text, key, open_ch, close_ch):
|
||
# 从固定 JSON 文本中提取数组或对象片段,例如 face_profiles: [...]。
|
||
# 使用括号深度计数,避免数组内部对象的右括号提前截断。
|
||
marker = '"{}":'.format(key)
|
||
pos = text.find(marker)
|
||
if pos == -1:
|
||
return ""
|
||
start = text.find(open_ch, pos + len(marker))
|
||
if start == -1:
|
||
return ""
|
||
depth = 0
|
||
for i in range(start, len(text)):
|
||
ch = text[i]
|
||
if ch == open_ch:
|
||
depth += 1
|
||
elif ch == close_ch:
|
||
depth -= 1
|
||
if depth == 0:
|
||
return text[start:i + 1]
|
||
return ""
|
||
|
||
# 函数作用:解析 JSON 字符串数组片段。
|
||
def parse_string_array_block(arr_str):
|
||
# 解析 ["1","2"] 这类简单字符串数组,用于 pending_delete 列表。
|
||
values = []
|
||
pos = 0
|
||
while pos < len(arr_str):
|
||
start = arr_str.find('"', pos)
|
||
if start == -1:
|
||
break
|
||
end = arr_str.find('"', start + 1)
|
||
if end == -1:
|
||
break
|
||
values.append(arr_str[start + 1:end])
|
||
pos = end + 1
|
||
return values
|
||
|
||
# 函数作用:解析服务端下发的待删除人脸任务列表。
|
||
def parse_delete_tasks(tasks_str):
|
||
# 解析删除任务数组。每个任务包含 task_id 和 face_id;
|
||
# task_id 用于服务端 ACK,face_id 用于删除本地特征、照片和映射。
|
||
tasks = []
|
||
pos = 0
|
||
while pos < len(tasks_str):
|
||
obj_start = tasks_str.find('{', pos)
|
||
if obj_start == -1:
|
||
break
|
||
obj_end = tasks_str.find('}', obj_start)
|
||
if obj_end == -1:
|
||
break
|
||
obj = tasks_str[obj_start:obj_end + 1]
|
||
task_id = json_get_string(obj, "task_id", "")
|
||
face_id = json_get_string(obj, "face_id", "")
|
||
if face_id:
|
||
tasks.append({"task_id": task_id, "face_id": face_id})
|
||
pos = obj_end + 1
|
||
return tasks
|
||
|
||
# 函数作用:从简单 JSON 文本中读取字符串字段。
|
||
def json_get_string(text, key, default=""):
|
||
# 从固定 JSON 对象中提取字符串字段。
|
||
# 该函数不处理复杂转义字符,适用于当前服务端返回的简单 ID/状态字段。
|
||
marker = '"{}":"'.format(key)
|
||
start = text.find(marker)
|
||
if start == -1:
|
||
return default
|
||
start += len(marker)
|
||
end = text.find('"', start)
|
||
if end == -1:
|
||
return default
|
||
return text[start:end]
|
||
|
||
# 函数作用:从服务端照片列表响应中解析人脸照片编号。
|
||
def parse_photo_ids(response):
|
||
"""解析服务端 /api/v1/faces/photos 返回的照片ID列表"""
|
||
ids = {}
|
||
pos = 0
|
||
marker = '"id":"'
|
||
while pos < len(response):
|
||
start = response.find(marker, pos)
|
||
if start == -1:
|
||
break
|
||
start += len(marker)
|
||
end = response.find('"', start)
|
||
if end == -1:
|
||
break
|
||
photo_id = response[start:end]
|
||
if photo_id:
|
||
ids[photo_id] = 1
|
||
pos = end + 1
|
||
return ids
|
||
|
||
# 函数作用:解析 JSON 对象片段为本地映射字典。
|
||
def parse_mapping_object(obj_str):
|
||
# 解析 {"1":"20210001","2":"20210002"} 形式的简单映射对象。
|
||
# 当前主要保留作兼容工具,统一同步逻辑优先使用 face_profiles 数组。
|
||
mappings = {}
|
||
if len(obj_str) < 2:
|
||
return mappings
|
||
inner = obj_str[1:-1].strip()
|
||
if not inner:
|
||
return mappings
|
||
parts = inner.split(',')
|
||
for part in parts:
|
||
if ':' not in part:
|
||
continue
|
||
kv = part.split(':', 1)
|
||
key = kv[0].strip().strip('"')
|
||
value = kv[1].strip().strip('"')
|
||
if key and value:
|
||
mappings[key] = value
|
||
return mappings
|
||
|
||
# 函数作用:应用服务端人脸档案映射并更新本地记录。
|
||
def apply_face_profiles(server_mappings):
|
||
# 应用服务端下发的人脸档案映射。
|
||
# server_mappings 的 key 是 face_id,value 是 student_id;
|
||
# 特殊值 "__PHOTO_ONLY__" 表示服务端只有照片占位,不应覆盖本地已有映射。
|
||
global face_id_mapping
|
||
print("[SYNC_STATE] 服务器映射数量: {}".format(len(server_mappings)))
|
||
updated_count = 0
|
||
valid_mappings = {}
|
||
# 先保留本地仍然有效的映射,顺手丢弃本地已经没有特征的人脸绑定。
|
||
for face_id, student_id in face_id_mapping.items():
|
||
if local_face_id_exists(face_id) and student_id:
|
||
valid_mappings[face_id] = student_id
|
||
|
||
for face_id, student_id in server_mappings.items():
|
||
# 服务端存在但本地没有特征的人脸,不写入映射。
|
||
# 这样可以避免管理端的旧照片或历史数据影响当前识别端。
|
||
if not local_face_id_exists(face_id):
|
||
print("[SYNC_STATE] 跳过本地不存在的face_id映射: ID:{} -> {}".format(face_id, student_id))
|
||
continue
|
||
if student_id == "__PHOTO_ONLY__":
|
||
# 照片占位通常来自先上传照片、后在管理端绑定学生的流程。
|
||
# 此时不清空本地映射,等待真正 student_id 下发。
|
||
print("[SYNC_STATE] 服务端仅有照片占位,保留本地映射: ID:{}".format(face_id))
|
||
continue
|
||
if not student_id:
|
||
# 服务端显式下发空 student_id,表示解除绑定。
|
||
if face_id in valid_mappings:
|
||
del valid_mappings[face_id]
|
||
updated_count += 1
|
||
print("[SYNC_STATE] 解除本地face_id绑定: ID:{}".format(face_id))
|
||
continue
|
||
print("[SYNC_STATE] 映射: ID:{} -> {}".format(face_id, student_id))
|
||
if update_face_record(face_id, student_id):
|
||
updated_count += 1
|
||
valid_mappings[face_id] = student_id
|
||
face_id_mapping = valid_mappings
|
||
save_local_mapping()
|
||
print("[SYNC_STATE] 映射应用完成,更新 {} 条".format(updated_count))
|
||
|
||
# 函数作用:从服务端同步统一状态,包括映射、照片和删除任务。
|
||
def sync_unified_state():
|
||
"""按服务端统一版本号同步K210所需状态"""
|
||
global sync_last_version
|
||
if not wifi_connected:
|
||
print("[SYNC_STATE] WiFi未连接,跳过")
|
||
return False
|
||
|
||
# 统一同步接口根据 client 和 last_version 返回 K210 需要处理的所有变化:
|
||
# - face_profiles:人脸与学号绑定关系;
|
||
# - delete_tasks/pending_delete:需要本地删除的人脸;
|
||
# - version/changed:服务端当前版本和是否有变化。
|
||
path = "/api/v1/sync/state?client=k210&last_version={}".format(sync_last_version)
|
||
print("[SYNC_STATE] GET {}".format(path))
|
||
response = esp_http_get(path, 8000)
|
||
if response is None:
|
||
print("[SYNC_STATE] 服务器无响应")
|
||
return False
|
||
|
||
server_version = json_get_int(response, "version", sync_last_version)
|
||
changed = ('"changed":true' in response)
|
||
print("[SYNC_STATE] local={}, server={}, changed={}".format(sync_last_version, server_version, changed))
|
||
|
||
if not changed:
|
||
# 服务端没有变化也保存版本号,防止本地版本落后但无需实际处理。
|
||
save_sync_version(server_version)
|
||
return True
|
||
|
||
profiles_arr = extract_json_block(response, "face_profiles", '[', ']')
|
||
server_mappings = {}
|
||
server_face_ids = {}
|
||
pos = 0
|
||
while profiles_arr and pos < len(profiles_arr):
|
||
obj_start = profiles_arr.find('{', pos)
|
||
if obj_start == -1:
|
||
break
|
||
obj_end = profiles_arr.find('}', obj_start)
|
||
if obj_end == -1:
|
||
break
|
||
obj = profiles_arr[obj_start:obj_end + 1]
|
||
face_id = json_get_string(obj, "face_id", "")
|
||
student_id = json_get_string(obj, "student_id", "")
|
||
status = json_get_string(obj, "status", "")
|
||
if face_id:
|
||
# status=photo_only 表示该 face_id 在服务端只有照片,没有绑定学号。
|
||
# 用特殊占位值传给 apply_face_profiles,避免误删本地已有绑定。
|
||
server_face_ids[face_id] = 1
|
||
if status == "photo_only" and not student_id:
|
||
student_id = "__PHOTO_ONLY__"
|
||
server_mappings[face_id] = student_id
|
||
pos = obj_end + 1
|
||
|
||
tasks_arr = extract_json_block(response, "delete_tasks", '[', ']')
|
||
delete_tasks = parse_delete_tasks(tasks_arr)
|
||
pending_arr = extract_json_block(response, "pending_delete", '[', ']')
|
||
pending_delete = parse_string_array_block(pending_arr)
|
||
if not delete_tasks:
|
||
# 兼容旧接口:只有 pending_delete 字符串数组时,临时转换成统一任务格式。
|
||
delete_tasks = [{"task_id": "", "face_id": face_id} for face_id in pending_delete]
|
||
deleted_ids = []
|
||
deleted_task_ids = []
|
||
delete_failed = False
|
||
print("[SYNC_STATE] 待删除任务: {}".format(delete_tasks))
|
||
|
||
for task in delete_tasks:
|
||
face_id = task["face_id"]
|
||
if face_id in server_face_ids:
|
||
# 如果同一轮响应里仍然包含该 face_id 的服务端档案,说明这是旧删除任务。
|
||
# 这时不删除本地人脸,但仍 ACK,防止服务端重复下发。
|
||
print("[SYNC_STATE] 忽略旧删除任务,服务端仍有人脸记录: {}".format(face_id))
|
||
deleted_ids.append(face_id)
|
||
if task["task_id"]:
|
||
deleted_task_ids.append(task["task_id"])
|
||
continue
|
||
if not local_face_id_exists(face_id):
|
||
# 本地已经没有这张脸,等价于删除完成,直接 ACK。
|
||
print("[SYNC_STATE] 删除任务face_id本地不存在,跳过但ACK: {}".format(face_id))
|
||
deleted_ids.append(face_id)
|
||
if task["task_id"]:
|
||
deleted_task_ids.append(task["task_id"])
|
||
continue
|
||
if delete_face_by_id(face_id):
|
||
deleted_ids.append(face_id)
|
||
if task["task_id"]:
|
||
deleted_task_ids.append(task["task_id"])
|
||
else:
|
||
delete_failed = True
|
||
|
||
if deleted_ids:
|
||
# 删除完成后向服务端 ACK。只有 ACK 成功才推进本地版本,
|
||
# 否则下次同步继续重试,避免服务端和 K210 状态不一致。
|
||
deleted_json = ",".join(['"{}"'.format(x) for x in deleted_ids])
|
||
task_json = ",".join(['"{}"'.format(x) for x in deleted_task_ids])
|
||
ack_body = '{{"client":"k210","version":{},"deleted":[{}],"task_ids":[{}]}}'.format(server_version, deleted_json, task_json)
|
||
ack_resp = esp_http_post_json("/api/v1/sync/ack", ack_body, 3000)
|
||
print("[SYNC_STATE] ACK响应:", ack_resp[:120] if ack_resp else "(空)")
|
||
ack_compact = ack_resp.replace(" ", "").replace("\r", "").replace("\n", "") if ack_resp else ""
|
||
ack_ok = '"success":true' in ack_compact
|
||
if not ack_ok:
|
||
delete_failed = True
|
||
print("[SYNC_STATE] 删除ACK失败,暂不推进本地版本,等待下次重试")
|
||
|
||
# 删除任务处理完成后再应用映射,避免先写入后又删除造成短暂脏状态。
|
||
apply_face_profiles(server_mappings)
|
||
|
||
if not delete_failed:
|
||
save_sync_version(server_version)
|
||
else:
|
||
print("[SYNC_STATE] 有删除失败项,暂不推进本地版本,等待下次重试")
|
||
|
||
return not delete_failed
|
||
|
||
# --- K210时间管理 ---
|
||
k210_base_timestamp = 0 # 基准时间戳(Unix时间戳)
|
||
k210_base_ticks = 0 # 基准时刻的ticks
|
||
|
||
# 函数作用:根据服务端时间设置 K210 本地时间基准。
|
||
def set_base_time(unix_timestamp):
|
||
"""设置K210的基准时间"""
|
||
# K210 不带可靠 RTC,这里用“服务端/STM32 给定的 Unix 时间 + 本机 ticks 增量”
|
||
# 估算当前时间,适合生成临时日志时间或调试信息。
|
||
global k210_base_timestamp, k210_base_ticks
|
||
k210_base_timestamp = unix_timestamp
|
||
k210_base_ticks = utime.ticks_ms()
|
||
print("[TIME] Base time set:", unix_timestamp)
|
||
|
||
# 函数作用:基于本地时间基准生成当前时间戳字符串。
|
||
def get_current_timestamp():
|
||
"""获取当前Unix时间戳"""
|
||
if k210_base_timestamp == 0:
|
||
# 如果还没设置基准时间,使用运行时间
|
||
return utime.ticks_ms() // 1000
|
||
else:
|
||
# 计算当前时间戳
|
||
elapsed_seconds = (utime.ticks_ms() - k210_base_ticks) // 1000
|
||
return k210_base_timestamp + elapsed_seconds
|
||
|
||
# --- 2. KPU模型和硬件初始化 ---
|
||
# 三个模型分别对应人脸识别流水线的三个阶段:
|
||
# task_fd:FaceDetection,检测画面中是否有人脸以及人脸框位置;
|
||
# task_ld:FaceLandmarkDetection,定位眼睛、鼻子、嘴角等关键点;
|
||
# task_fe:FeatureExtraction,提取 128 维左右的人脸特征向量用于比对。
|
||
try:
|
||
task_fd = kpu.load("/sd/FaceDetection.smodel")
|
||
task_ld = kpu.load("/sd/FaceLandmarkDetection.smodel")
|
||
task_fe = kpu.load("/sd/FeatureExtraction.smodel")
|
||
except Exception as e:
|
||
print("Error loading KPU models:", e)
|
||
while True:
|
||
utime.sleep_ms(1000)
|
||
|
||
clock = time.clock()
|
||
|
||
# RGB LED 引脚映射。这里高电平为灭、低电平为亮:
|
||
# 绿灯亮:识别到已知人员;
|
||
# 红灯亮:检测到陌生人、删除警告或操作失败;
|
||
# 全灭/全亮状态用于提示不同运行阶段。
|
||
fm.register(12, fm.fpioa.GPIO0)
|
||
fm.register(13, fm.fpioa.GPIO1)
|
||
fm.register(14, fm.fpioa.GPIO2)
|
||
LED_B = GPIO(GPIO.GPIO0, GPIO.OUT, value=1)
|
||
LED_G = GPIO(GPIO.GPIO1, GPIO.OUT, value=1)
|
||
LED_R = GPIO(GPIO.GPIO2, GPIO.OUT, value=1)
|
||
key_pin = 16
|
||
fm.register(key_pin, fm.fpioa.GPIOHS0)
|
||
KEY = GPIO(GPIO.GPIOHS0, GPIO.IN)
|
||
|
||
# 摄像头和 LCD 初始化。
|
||
# QVGA(320x240) 是 K210 人脸检测常用分辨率,速度和识别效果比较平衡。
|
||
lcd.init()
|
||
sensor.reset()
|
||
sensor.set_pixformat(sensor.RGB565)
|
||
sensor.set_framesize(sensor.QVGA)
|
||
sensor.set_hmirror(CAMERA_HMIRROR)
|
||
sensor.set_vflip(CAMERA_VFLIP)
|
||
sensor.run(1)
|
||
anchor = (1.889, 2.5245, 2.9465, 3.94056, 3.99987, 5.3658, 5.155437, 6.92275, 6.718375, 9.01025)
|
||
dst_point = [(44, 59), (84, 59), (64, 82), (47, 105), (81, 105)]
|
||
|
||
# 初始化 YOLOv2 人脸检测参数:
|
||
# 0.5 为置信度阈值,低于该分数的人脸框会被过滤;
|
||
# 0.3 为 NMS 阈值,用于去掉重叠框;
|
||
# anchor 是模型训练时使用的人脸框先验尺寸。
|
||
kpu.init_yolo2(task_fd, 0.5, 0.3, 5, anchor)
|
||
|
||
# img_face 是送入特征提取模型的标准 128x128 人脸图。
|
||
# 检测到人脸后,会根据关键点把原图人脸仿射对齐到这个画布上。
|
||
img_face = image.Image(size=(128, 128))
|
||
img_face.pix_to_ai()
|
||
|
||
# --- 3. 按键中断处理 ---
|
||
key_Flag = 0
|
||
key_pressed = 0 # 0=无, 1=短按, 2=长按(2秒), 3=超长按(5秒删除全部)
|
||
t_key = 0
|
||
# 函数作用:处理按键中断并切换人脸录入相关状态。
|
||
def fun(KEY):
|
||
# 按键中断只负责记录“按下持续时间”对应的事件类型,不在中断里做耗时操作。
|
||
# 主循环随后根据 key_pressed 切换到添加、删除或清空模式。
|
||
global key_Flag, key_pressed, t_key
|
||
utime.sleep_ms(10)
|
||
if KEY.value() == 0:
|
||
if key_Flag == 0:
|
||
key_Flag = 1
|
||
t_key = utime.ticks_ms()
|
||
else:
|
||
if key_Flag == 1:
|
||
press_duration = utime.ticks_diff(utime.ticks_ms(), t_key)
|
||
if press_duration > 5000:
|
||
key_pressed = 3 # 超长按5秒:删除全部人脸
|
||
elif press_duration > 2000:
|
||
key_pressed = 2 # 长按2秒:删除模式
|
||
else:
|
||
key_pressed = 1 # 短按:添加人脸
|
||
key_Flag = 0
|
||
KEY.irq(fun, GPIO.IRQ_BOTH)
|
||
|
||
# 函数作用:清空本地人脸特征库和相关照片缓存。
|
||
def delete_all_faces():
|
||
"""删除全部人脸数据"""
|
||
global peoples, face_id_mapping
|
||
try:
|
||
# 清空内存中的人脸列表。
|
||
peoples = []
|
||
# 清空 SD 卡特征记录文件。
|
||
with open(record_path, "w") as f:
|
||
pass # 写入空文件
|
||
# 清空学号映射和本地照片,避免服务端或下次启动恢复旧状态。
|
||
face_id_mapping = {}
|
||
save_local_mapping()
|
||
delete_all_local_face_photos()
|
||
print("[DELETE_ALL] 已删除全部人脸数据")
|
||
return True
|
||
except Exception as e:
|
||
print("[DELETE_ALL] 删除失败:", e)
|
||
load_faces()
|
||
return False
|
||
|
||
# --- 4. 数据库和人脸管理函数 ---
|
||
# recordftr.txt 每行保存一张人脸:
|
||
# ID:1#bytearray(b"...")
|
||
# 左侧是 K210 原始 face_id,右侧是 KPU 提取的人脸特征。
|
||
# 由于历史版本可能把特征保存成 bytearray 文本、bytes 文本或数字列表,
|
||
# 下面的解析函数专门做兼容读取,避免使用 eval 带来的安全和稳定性问题。
|
||
class People:
|
||
# 函数作用:初始化人脸人员对象,保存名称和特征向量。
|
||
def __init__(self, name="", feature=[]): self.name = name; self.feature = feature
|
||
# 函数作用:计算输入特征与当前人员特征集合的最大相似度。
|
||
def feature_max(self, feature):
|
||
# 返回当前人脸特征与待比对特征的相似度分数,分数越高表示越像。
|
||
if not self.feature: return 0
|
||
return kpu.face_compare(self.feature, feature)
|
||
peoples = []
|
||
record_path = "/sd/recordftr.txt"
|
||
|
||
# 函数作用:把单个十六进制字符转换为整数。
|
||
def hex_value(ch):
|
||
# 将单个十六进制字符转换为 0~15,用于解析 \xAB 形式的字节转义。
|
||
if '0' <= ch <= '9':
|
||
return ord(ch) - ord('0')
|
||
if 'a' <= ch <= 'f':
|
||
return ord(ch) - ord('a') + 10
|
||
if 'A' <= ch <= 'F':
|
||
return ord(ch) - ord('A') + 10
|
||
return -1
|
||
|
||
# 函数作用:解析 Python bytes 字面量文本为字节值列表。
|
||
def parse_bytes_literal(text):
|
||
"""安全解析 bytearray(b"...") / b"..." 文本,不使用 eval。"""
|
||
pos1 = text.find("b'")
|
||
pos2 = text.find('b"')
|
||
if pos1 < 0 and pos2 < 0:
|
||
raise ValueError("bytes literal not found")
|
||
if pos1 >= 0 and (pos2 < 0 or pos1 < pos2):
|
||
pos = pos1
|
||
quote = "'"
|
||
else:
|
||
pos = pos2
|
||
quote = '"'
|
||
|
||
i = pos + 2
|
||
values = []
|
||
while i < len(text):
|
||
ch = text[i]
|
||
if ch == quote:
|
||
# 特征向量长度过短通常说明文件损坏或截断,直接拒绝加载该行。
|
||
if len(values) < 64:
|
||
raise ValueError("feature too short")
|
||
return bytearray(values)
|
||
|
||
if ch != "\\":
|
||
# 普通可见字符按 ASCII/字节值写入。
|
||
values.append(ord(ch))
|
||
i += 1
|
||
else:
|
||
# 处理 Python bytes 文本中的转义序列,例如 \x12、\n、\\ 等。
|
||
i += 1
|
||
if i >= len(text):
|
||
raise ValueError("bad escape")
|
||
esc = text[i]
|
||
if esc == "x":
|
||
if i + 2 >= len(text):
|
||
raise ValueError("bad hex escape")
|
||
hi = hex_value(text[i + 1])
|
||
lo = hex_value(text[i + 2])
|
||
if hi < 0 or lo < 0:
|
||
raise ValueError("bad hex escape")
|
||
values.append(hi * 16 + lo)
|
||
i += 3
|
||
elif esc == "n":
|
||
values.append(10)
|
||
i += 1
|
||
elif esc == "r":
|
||
values.append(13)
|
||
i += 1
|
||
elif esc == "t":
|
||
values.append(9)
|
||
i += 1
|
||
elif esc == "b":
|
||
values.append(8)
|
||
i += 1
|
||
elif esc == "f":
|
||
values.append(12)
|
||
i += 1
|
||
elif esc == "v":
|
||
values.append(11)
|
||
i += 1
|
||
elif esc == "a":
|
||
values.append(7)
|
||
i += 1
|
||
elif esc == "\\":
|
||
values.append(92)
|
||
i += 1
|
||
elif esc == quote:
|
||
values.append(ord(quote))
|
||
i += 1
|
||
elif '0' <= esc <= '7':
|
||
val = ord(esc) - ord('0')
|
||
i += 1
|
||
count = 1
|
||
while count < 3 and i < len(text) and '0' <= text[i] <= '7':
|
||
val = val * 8 + ord(text[i]) - ord('0')
|
||
i += 1
|
||
count += 1
|
||
values.append(val & 0xff)
|
||
else:
|
||
values.append(ord(esc))
|
||
i += 1
|
||
|
||
if len(values) > 4096:
|
||
# 正常人脸特征远小于 4096 字节,超过该值基本可以判断为异常数据。
|
||
raise ValueError("feature too long")
|
||
|
||
raise ValueError("unterminated bytes literal")
|
||
|
||
# 函数作用:解析逗号分隔的数字列表文本。
|
||
def parse_number_list(text):
|
||
# 兼容 [1, 2, 3] 或 [0.1, 0.2, ...] 形式的历史特征记录。
|
||
# 若全部数值都是 0~255 的整数,则转为 bytearray;否则保留浮点列表。
|
||
start = text.find("[")
|
||
end = text.rfind("]")
|
||
if start < 0 or end <= start:
|
||
raise ValueError("feature must be bytearray/list")
|
||
|
||
inner = text[start + 1:end].strip()
|
||
if not inner:
|
||
raise ValueError("empty feature")
|
||
|
||
values = []
|
||
has_float = False
|
||
all_byte = True
|
||
for token in inner.split(","):
|
||
token = token.strip()
|
||
if not token:
|
||
raise ValueError("empty number")
|
||
if "." in token or "e" in token or "E" in token:
|
||
value = float(token)
|
||
has_float = True
|
||
all_byte = False
|
||
else:
|
||
value = int(token)
|
||
if value < 0 or value > 255:
|
||
all_byte = False
|
||
values.append(value)
|
||
if len(values) > 4096:
|
||
raise ValueError("feature too long")
|
||
|
||
if len(values) < 64:
|
||
raise ValueError("feature too short")
|
||
if all_byte and not has_float:
|
||
return bytearray(values)
|
||
return values
|
||
|
||
# 函数作用:解析人脸特征向量的文本存储格式。
|
||
def parse_feature_vector(text):
|
||
# 根据文本前缀自动选择解析方式,统一返回 kpu.face_compare 可接受的特征对象。
|
||
text = text.strip()
|
||
if text.startswith("bytearray(") or text.startswith("b'") or text.startswith('b"'):
|
||
return parse_bytes_literal(text)
|
||
return parse_number_list(text)
|
||
|
||
HEX_CHARS = "0123456789abcdef"
|
||
|
||
# 函数作用:把单个字节值转换为两位十六进制文本。
|
||
def byte_to_hex(v):
|
||
# 不依赖格式化函数生成两位十六进制,减少 MaixPy 兼容性问题。
|
||
return HEX_CHARS[(v >> 4) & 15] + HEX_CHARS[v & 15]
|
||
|
||
# 函数作用:把字节值列表转换为可写入记录文件的转义文本。
|
||
def escape_byte_values(values):
|
||
# 将 bytearray 转回可读的 bytes 字面量文本。
|
||
# 可见字符直接写,控制字符和非 ASCII 字节使用转义,便于下次安全解析。
|
||
out = ""
|
||
for v in values:
|
||
v = int(v) & 0xff
|
||
if v == 92:
|
||
out += "\\\\"
|
||
elif v == 34:
|
||
out += '\\"'
|
||
elif v == 10:
|
||
out += "\\n"
|
||
elif v == 13:
|
||
out += "\\r"
|
||
elif v == 9:
|
||
out += "\\t"
|
||
elif 32 <= v <= 126:
|
||
out += chr(v)
|
||
else:
|
||
out += "\\x" + byte_to_hex(v)
|
||
return out
|
||
|
||
# 函数作用:把人脸特征向量转换为本地记录文件文本。
|
||
def feature_to_record_text(feature):
|
||
# 保存特征时尽量保持 bytearray(b"...") 这种紧凑格式;
|
||
# 若当前特征对象不是 bytes/bytearray,则退回 str(feature)。
|
||
type_text = str(type(feature))
|
||
if type_text.find("bytearray") >= 0 or type_text.find("bytes") >= 0:
|
||
values = []
|
||
for v in feature:
|
||
values.append(int(v) & 0xff)
|
||
return 'bytearray(b"' + escape_byte_values(values) + '")'
|
||
return str(feature)
|
||
|
||
# 函数作用:把当前内存中的人脸库写入 SD 卡记录文件。
|
||
def write_face_record():
|
||
# 将内存中的 peoples 完整写回 recordftr.txt。
|
||
# 删除人脸时采用重写文件的方式,保证文件里不残留旧记录。
|
||
with open(record_path, "w") as f:
|
||
for p in peoples:
|
||
f.write(str(p.name) + '#' + feature_to_record_text(p.feature) + "\n")
|
||
|
||
# 函数作用:从 SD 卡记录文件加载本地人脸特征库。
|
||
def load_faces():
|
||
# 从 SD 卡加载本地人脸特征库。
|
||
# 单行解析失败不会终止整个加载过程,只统计失败数量并继续读取后续记录。
|
||
global peoples; peoples = []
|
||
total_count = 0
|
||
bad_count = 0
|
||
print("[FACE_DB] Loading {}".format(record_path))
|
||
try:
|
||
with open(record_path, "r") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
total_count += 1
|
||
# 使用 maxsplit=1 参数,只按第一个 '#' 分割,防止特征数据中的'#'导致错误
|
||
parts = line.split('#', 1)
|
||
if len(parts) == 2 and parts[1]:
|
||
try:
|
||
peoples.append(People(parts[0], parse_feature_vector(parts[1])))
|
||
except Exception as e_eval:
|
||
bad_count += 1
|
||
print("Feature parse error on line {} for {}: {}".format(total_count, parts[0], e_eval))
|
||
else:
|
||
bad_count += 1
|
||
print("[FACE_DB] Bad line {}: missing feature".format(total_count))
|
||
if total_count == 0:
|
||
print("[FACE_DB] recordftr.txt is empty")
|
||
print("成功加载 %d 条人脸数据 (总行:%d, 失败:%d)" % (len(peoples), total_count, bad_count))
|
||
except Exception as e: print("读取人脸数据库失败:", e)
|
||
|
||
# 函数作用:收集当前本地人脸库中已经使用的人脸编号。
|
||
def collect_used_face_ids():
|
||
"""收集本地已经占用的K210原始face_id,避免新增时覆盖旧人脸。"""
|
||
used = {}
|
||
|
||
# 先从特征库收集已存在的 ID。
|
||
for p in peoples:
|
||
name = str(p.name)
|
||
if name.startswith("ID:") and name.split(":", 1)[1].isdigit():
|
||
used[name.split(":", 1)[1]] = 1
|
||
|
||
# 再从映射文件收集 ID,防止特征和映射短暂不同步时重复分配。
|
||
for face_id in face_id_mapping.keys():
|
||
face_id = str(face_id)
|
||
if face_id.isdigit():
|
||
used[face_id] = 1
|
||
|
||
# 最后从本地照片目录收集 ID,避免存在照片但特征尚未成功写入时被覆盖。
|
||
if ensure_local_photo_dir():
|
||
try:
|
||
for filename in os.listdir(LOCAL_FACE_PHOTO_DIR):
|
||
if filename.endswith(".jpg"):
|
||
face_id = filename[:-4]
|
||
if face_id.isdigit():
|
||
used[face_id] = 1
|
||
except Exception as e:
|
||
print("[ADD] 扫描本地照片ID失败:", e)
|
||
|
||
return used
|
||
|
||
# 函数作用:分配下一个可用的人脸编号。
|
||
def allocate_next_face_id():
|
||
# 从 1 开始寻找最小可用 face_id,便于现场查看和管理端展示。
|
||
used = collect_used_face_ids()
|
||
next_id = 1
|
||
while str(next_id) in used:
|
||
next_id += 1
|
||
print("[ADD] 已占用face_id: {}, 新face_id: {}".format(sorted(used.keys()), next_id))
|
||
return next_id
|
||
|
||
# 函数作用:新增人脸特征记录,并保存或上传对应照片。
|
||
def add_face(feature, face_img=None):
|
||
# 新增人脸的顺序很重要:
|
||
# 1. 分配未占用 face_id;
|
||
# 2. 先保存照片并加入上传队列;
|
||
# 3. 再把特征追加写入 recordftr.txt。
|
||
# 若任一步失败,会回滚内存列表和照片,避免出现“有特征无照片”的半完成状态。
|
||
new_id_num = allocate_next_face_id()
|
||
new_name = "ID:{}".format(new_id_num)
|
||
peoples.append(People(new_name, feature))
|
||
try:
|
||
if face_img is not None:
|
||
print("[PHOTO] 正在保存人脸照片并加入上传队列...")
|
||
if not capture_and_upload_face(new_id_num, face_img):
|
||
print("[PHOTO] 本地照片保存失败,取消添加人脸")
|
||
peoples.pop()
|
||
return False
|
||
|
||
with open(record_path, "a") as f:
|
||
f.write(new_name + '#' + feature_to_record_text(feature) + "\n")
|
||
print("添加成功:", new_name)
|
||
|
||
return True
|
||
except Exception as e:
|
||
print("写入文件失败:", e)
|
||
delete_local_face_photo(new_id_num)
|
||
peoples.pop()
|
||
return False
|
||
# 函数作用:按本地列表索引删除人脸记录。
|
||
def delete_face_by_index(idx):
|
||
# 本地按索引删除,用于 K210 屏幕上的手动删除模式。
|
||
# 删除后重写特征文件,并同步删除对应照片。
|
||
if not (0 <= idx < len(peoples)): return False
|
||
deleted_name = peoples[idx].name
|
||
peoples.pop(idx)
|
||
try:
|
||
write_face_record()
|
||
if deleted_name.startswith("ID:"):
|
||
delete_local_face_photo(deleted_name.split(":", 1)[1])
|
||
else:
|
||
delete_local_face_photo(deleted_name)
|
||
print("删除 %s 成功" % deleted_name); return True
|
||
except Exception as e: print("重写文件失败:", e); load_faces(); return False
|
||
|
||
# --- 5. 支持多张人脸检测的函数 ---
|
||
# 函数作用:检测图像中的人脸并执行本地特征识别。
|
||
def detect_and_recognize_faces(img):
|
||
# 完整识别流程:
|
||
# 1. YOLOv2 检测当前画面中的全部人脸框;
|
||
# 2. 对每张脸做人脸关键点检测;
|
||
# 3. 根据关键点做仿射变换,将人脸对齐到 128x128 标准图;
|
||
# 4. 提取特征并与本地特征库逐一比对;
|
||
# 5. 分数超过阈值认为是已知人员,否则标记为 stranger。
|
||
codes = kpu.run_yolo2(task_fd, img)
|
||
if not codes: return []
|
||
results = []
|
||
for item in codes:
|
||
face_cut = img.cut(item.x(), item.y(), item.w(), item.h())
|
||
face_cut_128 = face_cut.resize(128, 128); face_cut_128.pix_to_ai()
|
||
fmap = kpu.forward(task_ld, face_cut_128)
|
||
plist = fmap[:]
|
||
# 根据模型输出的五点关键点计算左眼、右眼、鼻尖、左嘴角、右嘴角坐标。
|
||
le=(item.x()+int(plist[0]*item.w()-10), item.y()+int(plist[1]*item.h())); re=(item.x()+int(plist[2]*item.w()), item.y()+int(plist[3]*item.h())); nose=(item.x()+int(plist[4]*item.w()), item.y()+int(plist[5]*item.h())); lm=(item.x()+int(plist[6]*item.w()), item.y()+int(plist[7]*item.h())); rm=(item.x()+int(plist[8]*item.w()), item.y()+int(plist[9]*item.h()))
|
||
src_point = [le, re, nose, lm, rm]
|
||
# 将当前人脸的五点关键点映射到固定模板点 dst_point,减少姿态和尺度差异。
|
||
T = image.get_affine_transform(src_point, dst_point)
|
||
image.warp_affine_ai(img, img_face, T)
|
||
del(face_cut); del(face_cut_128)
|
||
fmap = kpu.forward(task_fe, img_face)
|
||
feature = kpu.face_encode(fmap[:])
|
||
max_score = 0; match_index = -1
|
||
# 与本地所有人脸逐一比较,取相似度最高的一条作为识别结果。
|
||
for i, p in enumerate(peoples):
|
||
score = p.feature_max(feature)
|
||
if score > max_score: max_score = score; match_index = i
|
||
result_item = {'score': max_score, 'rect': item.rect(), 'feature': feature}
|
||
# 85 是经验阈值:过低容易误识别,过高会降低识别通过率。
|
||
if max_score > 85: result_item['state'] = peoples[match_index].name
|
||
else: result_item['state'] = "stranger"
|
||
results.append(result_item)
|
||
return results
|
||
|
||
# --- 6. 主程序启动前准备 - 添加发送逻辑所需变量 ---
|
||
load_faces()
|
||
last_send_time = 0
|
||
SEND_INTERVAL_MS = 1000 # 串口上报最小间隔,避免同一张脸每帧都发送给 STM32
|
||
last_sent_data = "" # 记录上一次完整串口消息,内容不变时不重复发送
|
||
last_saved_id = "" # 预留字段:记录最近一次保存/处理的学号,切换状态时重置
|
||
|
||
# --- 分时网络:照片上传队列操作 ---
|
||
# 函数作用:把指定人脸编号加入照片待上传队列。
|
||
def queue_photo_upload(face_id):
|
||
"""将本地照片ID添加到上传队列(非阻塞)"""
|
||
global photo_upload_queue
|
||
face_id = str(face_id)
|
||
if face_id in photo_upload_queue:
|
||
print("[QUEUE] 照片已在队列中: ID={}".format(face_id))
|
||
return
|
||
photo_upload_queue.append(face_id)
|
||
print("[QUEUE] 照片已加入队列: ID={},队列长度: {}".format(face_id, len(photo_upload_queue)))
|
||
|
||
# 函数作用:扫描本地照片缓存并加入待上传队列。
|
||
def queue_local_photos_for_upload():
|
||
"""启动时扫描SD卡照片,补传可能遗漏到服务器的照片"""
|
||
# K210 可能在离线状态下新增人脸,此时照片只能先存到 SD 卡。
|
||
# 启动时先向服务端查询已有照片,服务端没有的才加入队列,减少重复上传。
|
||
if not ensure_local_photo_dir():
|
||
return
|
||
|
||
server_photo_ids = None
|
||
if wifi_connected:
|
||
print("[QUEUE] 正在获取服务端照片列表,用于判断缺失照片...")
|
||
response = esp_http_get("/api/v1/faces/photos", 8000)
|
||
if response:
|
||
server_photo_ids = parse_photo_ids(response)
|
||
print("[QUEUE] 服务端已有照片: {} 张".format(len(server_photo_ids)))
|
||
else:
|
||
print("[QUEUE] 服务端照片列表获取失败,将本地照片全部加入补传队列")
|
||
|
||
queued = 0
|
||
skipped = 0
|
||
try:
|
||
for name in os.listdir(LOCAL_FACE_PHOTO_DIR):
|
||
if name.endswith(".jpg"):
|
||
face_id = name[:-4]
|
||
if face_id:
|
||
if server_photo_ids is not None:
|
||
server_has_photo = face_id in server_photo_ids
|
||
if server_has_photo:
|
||
skipped += 1
|
||
continue
|
||
queue_photo_upload(face_id)
|
||
queued += 1
|
||
print("[QUEUE] 本地照片补传扫描完成: queued={}, skipped={}".format(queued, skipped))
|
||
except Exception as e:
|
||
print("[QUEUE] 扫描本地照片失败:", e)
|
||
|
||
# 函数作用:执行周期性网络任务,包括上传照片和同步服务端状态。
|
||
def do_network_task():
|
||
"""执行一个网络任务(每次只做一件事)"""
|
||
global photo_upload_queue, last_sync_time
|
||
|
||
# 当前分时任务优先处理照片上传。
|
||
# 上传失败会把 face_id 放回队尾,等待下次调度继续重试。
|
||
if len(photo_upload_queue) > 0:
|
||
face_id = photo_upload_queue.pop(0)
|
||
jpeg_data = load_local_face_photo(face_id)
|
||
if jpeg_data is None or len(jpeg_data) < 100:
|
||
print("[NET] 本地照片无效,跳过上传: ID={}".format(face_id))
|
||
return True
|
||
|
||
print("[NET] 上传照片: ID={}".format(face_id))
|
||
try:
|
||
if esp_http_post_photo(face_id, jpeg_data):
|
||
print("[NET] 照片上传完成")
|
||
else:
|
||
print("[NET] 照片上传失败,保留队列等待重试")
|
||
queue_photo_upload(face_id)
|
||
except Exception as e:
|
||
print("[NET] 上传错误:", e)
|
||
queue_photo_upload(face_id)
|
||
return True
|
||
|
||
return False
|
||
|
||
# 启动时扫描本地照片,补传可能遗漏到服务器的照片
|
||
queue_local_photos_for_upload()
|
||
|
||
# 启动时执行一次同步
|
||
print("[BOOT] 启动同步...")
|
||
if wifi_connected:
|
||
try:
|
||
sync_unified_state()
|
||
except Exception as e:
|
||
print("[BOOT] 统一同步错误:", e)
|
||
print("[BOOT] 同步完成")
|
||
else:
|
||
print("[BOOT] WiFi未连接,跳过同步")
|
||
|
||
# QT触发同步标志
|
||
sync_requested = False # 服务端提示需要同步后置为 True,主循环空闲时执行 trigger_sync
|
||
last_sync_check_time = 0 # 上一次检查服务端同步请求的时间
|
||
|
||
# 函数作用:检查服务端是否要求 K210 立即同步。
|
||
def check_sync_request():
|
||
"""检查服务器是否有同步请求"""
|
||
global sync_requested
|
||
if not wifi_connected:
|
||
return False
|
||
|
||
try:
|
||
response = esp_http_get("/api/v1/sync/request", 2000)
|
||
if response and '"sync":true' in response:
|
||
print("[SYNC] 收到服务器同步请求")
|
||
sync_requested = True
|
||
# 确认收到请求。实际同步放到主循环中执行,避免接口轮询阶段连续阻塞。
|
||
esp_http_post_json("/api/v1/sync/request/ack", '{"ack":true}', 1000)
|
||
return True
|
||
except Exception as e:
|
||
print("[SYNC] 检查同步请求错误:", e)
|
||
return False
|
||
|
||
# 函数作用:主动触发一次服务端统一状态同步。
|
||
def trigger_sync():
|
||
"""执行一次完整同步(由QT端触发)"""
|
||
global sync_requested
|
||
if not wifi_connected:
|
||
print("[SYNC] WiFi未连接,无法同步")
|
||
return False
|
||
|
||
print("[SYNC] ========== QT触发同步开始 ==========")
|
||
try:
|
||
sync_unified_state()
|
||
except Exception as e:
|
||
print("[SYNC] 统一同步错误:", e)
|
||
|
||
print("[SYNC] ========== QT触发同步完成 ==========")
|
||
sync_requested = False
|
||
return True
|
||
|
||
# --- 7. 主循环 (状态机) ---
|
||
# 主循环按 key_pressed 状态分为四种模式:
|
||
# 0:默认识别模式,持续采集画面、识别人脸,并按需发送给 STM32;
|
||
# 1:添加人脸模式,短按确认把当前陌生人保存为新 face_id;
|
||
# 2:删除人脸模式,短按切换条目,长按确认删除;
|
||
# 3:删除全部人脸模式,超长按进入二次确认。
|
||
# 每轮循环末尾还会做一次轻量的网络调度和 gc.collect(),保证识别任务长期稳定运行。
|
||
while True:
|
||
clock.tick()
|
||
# ------------------ 模式0:人脸识别与智能通信 (多人脸 & 无脸不发送) ------------------
|
||
if key_pressed == 0:
|
||
# 默认模式下,每帧采集图像并识别当前画面中的全部人脸。
|
||
img = sensor.snapshot()
|
||
detected_faces = detect_and_recognize_faces(img)
|
||
current_state = ""
|
||
recognized_ids = []
|
||
if not detected_faces:
|
||
# 无人脸时关闭红绿灯,不主动给 STM32 发送“无人脸”,减少串口噪声。
|
||
LED_G.value(1); LED_R.value(1)
|
||
else:
|
||
has_stranger = False; has_known_person = False
|
||
for face in detected_faces:
|
||
rect = face['rect']
|
||
img.draw_rectangle(rect)
|
||
if face['state'] == "stranger":
|
||
# 陌生人用红色标记,同时在待发送状态中记为 0。
|
||
img.draw_string(rect[0], rect[1], "Stranger", color=(255, 0, 0), scale=2)
|
||
recognized_ids.append("0"); has_stranger = True
|
||
else:
|
||
# 已知人脸优先显示映射后的学号;未绑定时显示原始 ID:x。
|
||
display_name = get_display_name(face['state'])
|
||
img.draw_string(rect[0], rect[1], display_name, color=(0, 255, 0), scale=2)
|
||
recognized_ids.append(face['state']); has_known_person = True
|
||
# LED 状态提示识别结果:绿灯代表已知人员,红灯代表只检测到陌生人。
|
||
if has_known_person: LED_G.value(0); LED_R.value(1)
|
||
elif has_stranger: LED_G.value(1); LED_R.value(0)
|
||
recognized_ids.sort(); current_state = ",".join(recognized_ids)
|
||
time_now = utime.ticks_ms()
|
||
if utime.ticks_diff(time_now, last_send_time) > SEND_INTERVAL_MS:
|
||
if detected_faces:
|
||
# 多人同时出现时,选择相似度最高的人脸发送给 STM32。
|
||
# 这样比“第一张脸”更稳定,能减少画面中小人脸或边缘人脸误触发。
|
||
best_face = max(detected_faces, key=lambda x: x['score'])
|
||
|
||
if best_face['state'] == "stranger":
|
||
# 陌生人发送 UNKNOWN,STM32 可据此提示未注册或拒绝签到。
|
||
msg_to_send = "UNKNOWN\r\n"
|
||
|
||
# 默认识别模式下陌生人不自动入库,只有进入添加模式后才保存。
|
||
last_saved_id = "" # 重置保存记录
|
||
else:
|
||
# 已识别用户发送 STM32 约定格式:
|
||
# ID:<学号或face_id>,NAME:<显示名>,CONF:<置信度>
|
||
# 若管理端尚未绑定学号,则使用 K210 原始 face_id 兜底,保证系统仍能跑通。
|
||
face_id = extract_face_id(best_face['state'])
|
||
mapped_student_id = get_mapped_student_id(best_face['state'])
|
||
if not mapped_student_id:
|
||
print("[UART SEND] Face {} has no student mapping, fallback to face_id".format(face_id))
|
||
student_id = mapped_student_id if mapped_student_id else face_id
|
||
student_name = mapped_student_id if mapped_student_id else "FACE_{}".format(face_id)
|
||
confidence = best_face['score'] / 100.0
|
||
|
||
# 构造 STM32 期望的格式;未绑定学号时默认使用 K210 原始 face_id。
|
||
msg_to_send = "ID:{},NAME:{},CONF:{:.2f}\r\n".format(
|
||
student_id,
|
||
student_name,
|
||
confidence
|
||
)
|
||
|
||
if msg_to_send != last_sent_data:
|
||
# 只有识别结果变化时才发送,避免 STM32 重复登记同一人。
|
||
uart.write(msg_to_send)
|
||
print("[UART SEND] -> {}".format(msg_to_send.strip()))
|
||
last_sent_data = msg_to_send
|
||
last_send_time = time_now
|
||
else:
|
||
# 从“有人脸”变为“无人脸”时只重置本地状态,不发送额外消息。
|
||
if last_sent_data != "":
|
||
print("[STATE CHANGE] Faces disappeared. No data sent.")
|
||
last_sent_data = ""
|
||
last_saved_id = "" # 重置保存记录
|
||
last_send_time = time_now
|
||
lcd.display(img)
|
||
|
||
# ------------------ 模式1:添加人脸 ------------------
|
||
elif key_pressed == 1:
|
||
key_pressed = 0
|
||
# 进入管理模式前清空上报状态,退出后下一次识别会重新发送。
|
||
last_sent_data = "" # 修改变量名
|
||
last_saved_id = "" # 重置保存记录
|
||
last_send_time = 0
|
||
print("进入添加人脸模式...")
|
||
while True:
|
||
img = sensor.snapshot()
|
||
img.draw_string(0, 0, "Add Face Mode", color=(0, 0, 255), scale=2)
|
||
img.draw_string(0, 24, "Click to Confirm", color=(0, 0, 255), scale=2)
|
||
detected_faces = detect_and_recognize_faces(img)
|
||
best_face = None
|
||
if detected_faces:
|
||
# 选出相似度最高/质量最稳定的脸进行操作。
|
||
best_face = max(detected_faces, key=lambda x: x['score'])
|
||
|
||
if best_face:
|
||
rect = best_face['rect'] # 获取目标人脸的框
|
||
if best_face['state'] != "stranger":
|
||
# 已存在的人脸不能重复添加,橙色框提示用户换人或退出。
|
||
img.draw_rectangle(rect, color=(255, 128, 0), thickness=2)
|
||
img.draw_string(rect[0], rect[1], "Exists!", color=(255, 128, 0), scale=2)
|
||
else:
|
||
# 陌生人可以作为新 face_id 添加,绿色框表示当前画面可确认。
|
||
img.draw_rectangle(rect, color=(0, 255, 0), thickness=2)
|
||
img.draw_string(rect[0], rect[1], "Ready", color=(0, 255, 0), scale=2)
|
||
|
||
# 按键确认逻辑:短按确认添加,长按退出添加模式。
|
||
if key_pressed == 1:
|
||
key_pressed = 0
|
||
if best_face:
|
||
if best_face['state'] != "stranger":
|
||
img.draw_string(best_face['rect'][0], best_face['rect'][1] + 24, "Already exists!", color=(255, 0, 0), scale=2); LED_R.value(0)
|
||
else:
|
||
# 新增时保存特征,同时把当前画面压缩为照片并加入上传队列。
|
||
if add_face(best_face['feature'], img):
|
||
img.draw_string(best_face['rect'][0], best_face['rect'][1] + 24, "Add OK!", color=(0, 255, 0), scale=2); LED_G.value(0)
|
||
else:
|
||
img.draw_string(best_face['rect'][0], best_face['rect'][1] + 24, "Add Failed!", color=(255, 0, 0), scale=2); LED_R.value(0)
|
||
else:
|
||
img.draw_string(10, 100, "No Face!", color=(255, 0, 0), scale=2); LED_R.value(0)
|
||
lcd.display(img); utime.sleep_ms(2000); LED_R.value(1); LED_G.value(1); break
|
||
if key_pressed == 2:
|
||
key_pressed = 0; break # 长按退出
|
||
lcd.display(img)
|
||
print("退出添加人脸模式")
|
||
|
||
# ------------------ 模式2:删除人脸 ------------------
|
||
elif key_pressed == 2:
|
||
key_pressed = 0
|
||
last_sent_data = "" # 修改变量名
|
||
last_saved_id = "" # 重置保存记录
|
||
last_send_time = 0
|
||
print("进入删除人脸模式...")
|
||
select_index = -1
|
||
while True:
|
||
img = image.Image(); img.clear()
|
||
img.draw_string(10, 10, "Delete Mode", color=(255, 0, 0), scale=2); img.draw_string(10, 40, "Click=Next, Long Press=Confirm", scale=1.8)
|
||
if key_pressed == 1:
|
||
# 短按在“取消”和各个人脸条目之间循环选择。
|
||
key_pressed = 0; select_index += 1
|
||
if select_index >= len(peoples): select_index = -1
|
||
if select_index == -1: img.draw_string(50, 100, "-> Cancel", color=(0, 255, 0), scale=2)
|
||
else: img.draw_string(50, 100, "-> Del: {}".format(peoples[select_index].name), color=(255, 128, 0), scale=2)
|
||
if key_pressed == 2:
|
||
# 长按确认当前选择;select_index=-1 表示取消删除。
|
||
key_pressed = 0
|
||
if select_index != -1:
|
||
if delete_face_by_index(select_index):
|
||
img.clear(); img.draw_string(50, 100, "Delete Success!", color=(0, 255, 0), scale=2); LED_G.value(0)
|
||
else:
|
||
img.clear(); img.draw_string(50, 100, "Delete Failed!", color=(255, 0, 0), scale=2); LED_R.value(0)
|
||
else:
|
||
img.clear(); img.draw_string(50, 100, "Canceled", color=(255, 255, 0), scale=2)
|
||
lcd.display(img); utime.sleep_ms(2000); LED_G.value(1); LED_R.value(1); load_faces(); break
|
||
lcd.display(img)
|
||
print("退出删除人脸模式")
|
||
|
||
# ------------------ 模式3:删除全部人脸 (长按5秒) ------------------
|
||
elif key_pressed == 3:
|
||
key_pressed = 0
|
||
print("进入删除全部人脸模式...")
|
||
img = image.Image(); img.clear()
|
||
img.draw_string(10, 10, "DELETE ALL FACES?", color=(255, 0, 0), scale=2)
|
||
img.draw_string(10, 50, "Count: {}".format(len(peoples)), color=(255, 255, 0), scale=2)
|
||
img.draw_string(10, 90, "Click=Confirm", color=(255, 128, 0), scale=1.8)
|
||
img.draw_string(10, 120, "Long Press=Cancel", color=(0, 255, 0), scale=1.8)
|
||
lcd.display(img)
|
||
LED_R.value(0) # 红灯警告
|
||
|
||
# 删除全部人脸属于高风险操作,因此进入该模式后还需要二次确认。
|
||
confirm_timeout = utime.ticks_ms()
|
||
while utime.ticks_diff(utime.ticks_ms(), confirm_timeout) < 10000: # 10秒超时
|
||
if key_pressed == 1: # 短按确认删除
|
||
key_pressed = 0
|
||
img.clear()
|
||
img.draw_string(30, 80, "Deleting...", color=(255, 0, 0), scale=2)
|
||
lcd.display(img)
|
||
if delete_all_faces():
|
||
img.clear()
|
||
img.draw_string(20, 80, "All Deleted!", color=(0, 255, 0), scale=2)
|
||
LED_R.value(1); LED_G.value(0)
|
||
else:
|
||
img.clear()
|
||
img.draw_string(20, 80, "Delete Failed!", color=(255, 0, 0), scale=2)
|
||
lcd.display(img)
|
||
utime.sleep_ms(2000)
|
||
break
|
||
elif key_pressed == 2 or key_pressed == 3: # 长按取消
|
||
key_pressed = 0
|
||
img.clear()
|
||
img.draw_string(50, 80, "Canceled", color=(255, 255, 0), scale=2)
|
||
lcd.display(img)
|
||
utime.sleep_ms(1500)
|
||
break
|
||
utime.sleep_ms(50)
|
||
|
||
LED_R.value(1); LED_G.value(1)
|
||
print("退出删除全部人脸模式")
|
||
|
||
# 分时网络处理:每N帧检查一次网络任务(只处理照片上传和同步请求)
|
||
# 网络调度放在所有模式处理之后执行。
|
||
# 每次只做一类任务,优先级为:
|
||
# 1. 上传本地照片;
|
||
# 2. 执行已收到的同步请求;
|
||
# 3. 定时向服务端询问是否需要同步。
|
||
frames_since_network += 1
|
||
if frames_since_network >= NETWORK_FRAME_INTERVAL:
|
||
frames_since_network = 0
|
||
# 优先处理照片上传队列
|
||
if len(photo_upload_queue) > 0:
|
||
do_network_task()
|
||
# 检查是否有同步请求待执行
|
||
elif sync_requested:
|
||
trigger_sync()
|
||
# 定期检查服务器是否有新的同步请求
|
||
elif wifi_connected and utime.ticks_diff(utime.ticks_ms(), last_sync_check_time) > SYNC_CHECK_INTERVAL_MS:
|
||
last_sync_check_time = utime.ticks_ms()
|
||
check_sync_request()
|
||
|
||
gc.collect()
|
||
|