commit 40def5153abfb9375cf7378ea4e409ecf477e349 Author: 纪田野 Date: Mon May 4 02:59:54 2026 +0800 Initial K210 code diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39befaa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.py[cod] +*.kfpkg +*.bin diff --git a/main.py b/main.py new file mode 100644 index 0000000..c5579f1 --- /dev/null +++ b/main.py @@ -0,0 +1,1784 @@ +# ==================================================================================== +# K210 V11 (分时网络通信) +# 功能更新: +# - 继承V10所有功能 +# - 【新功能】使用分时处理网络通信,每帧只做少量网络工作,避免长时间阻塞 +# ==================================================================================== + +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 + +# --- 增加堆内存大小 --- +# K210有8MB SRAM,默认堆较小,增加到512KB用于图像压缩 +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帧检查一次网络任务(约1秒) + +# --- 分时网络任务队列 --- +photo_upload_queue = [] # 照片上传队列 [face_id, ...],JPEG先保存在SD卡 +network_task_pending = False # 是否有网络任务待处理 +frames_since_network = 0 # 距离上次网络操作的帧数 + +# --- 0. ESP8266 WiFi配置 --- +WIFI_SSID = "yzfh" # 修改为你的WiFi名称 +WIFI_PASSWORD = "yzfhroot" # 修改为你的WiFi密码 +SERVER_IP = "27.106.113.205" # 修改为服务器IP地址 +SERVER_PORT = 8082 # 服务器HTTP端口 +API_TOKEN = "atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV" # 必须与服务端 ATTENDANCE_API_TOKEN 一致 +ESP_DEBUG = True # 打印ESP8266 AT响应,便于排查WiFi连接问题 +ESP_BAUD_RATES = (115200, 9600, 74880, 57600, 38400) +ESP_UART_BAUD = 115200 +ESP_BOOT_WAIT_MS = 6000 # 等待ESP8266上电启动和自动连网 +LOCAL_FACE_PHOTO_DIR = "/sd/face_photos" # 本地人脸照片缓存目录 + +# 摄像头安装方向:当前实物画面上下左右都反,水平镜像+垂直翻转等价于旋转180度。 +CAMERA_HMIRROR = 1 +CAMERA_VFLIP = 1 + +# --- 1. UART通信初始化 --- +# UART1: 连接STM32(原有功能) +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(照片上传功能) +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通信函数(优化版 - 减少阻塞)--- +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) + +def esp_clear_rx(): + """清空ESP8266串口残留数据""" + while uart2.any(): + uart2.read() + utime.sleep_ms(10) + +def esp_log_response(cmd, response): + if ESP_DEBUG: + text = response.replace('\r\n', ' ').strip() if response else "(空)" + print("[ESP] {} => {}".format(cmd, text[:180])) + +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 + +def esp_probe_uart(): + """探测ESP8266实际AT波特率""" + 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 + +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() + +def esp_check_wifi_status(): + """检查WiFi连接状态(快速检查)""" + resp = esp_send_cmd("AT+CWJAP?", 1500) + return WIFI_SSID in resp + +def esp_connect_wifi(): + """连接WiFi(单次尝试)""" + print("[ESP] Connecting to WiFi:", WIFI_SSID) + + resp = esp_send_cmd("ATE0", 1000) + + 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。部分路由器获取IP较慢,等待时间不能太短。 + 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 + +def esp_init_wifi(max_retries=5): + """初始化ESP8266并连接WiFi(减少重试次数)""" + print("[ESP] Initializing...") + esp_wait_boot() + esp_clear_rx() + + # 测试AT响应 + 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 + for i in range(max_retries): + if esp_connect_wifi(): + return True + utime.sleep_ms(1000) + + return False + +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卡本地照片目录存在""" + 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): + return "{}/{}.jpg".format(LOCAL_FACE_PHOTO_DIR, face_id) + +def save_local_face_photo(face_id, jpeg_data): + """先把人脸照片保存到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): + 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): + 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(): + 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) + +def esp_send_data(data, timeout=1500): + """发送数据并等待SEND OK(优化超时)""" + cmd = "AT+CIPSEND={}".format(len(data)) + uart2.write(cmd + "\r\n") + utime.sleep_ms(50) + + # 等待 > 提示符(缩短超时) + 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 + + # 发送数据 + 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 + +def esp_http_post_photo(face_id, jpeg_data, max_retries=2): + """通过ESP8266发送HTTP POST请求上传照片""" + # 直接使用face_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 + + # 关闭之前的连接 + esp_send_cmd("AT+CIPCLOSE", 500) + utime.sleep_ms(100) + + # 设置单连接模式 + esp_send_cmd("AT+CIPMUX=0", 500) + + # 建立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...") + + # 使用face_id作为文件名 + 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头 + if not esp_send_data(http_header): + print("[ESP] Send header failed") + esp_send_cmd("AT+CIPCLOSE", 500) + continue + + # 分块发送JPEG数据 + 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 + + # 等待服务器响应 + 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: + # 先复制图像,避免修改原图 + img_copy = img.copy() + + # 转换为RGB565格式(如果需要)后压缩为JPEG + 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) + # 尝试另一种方式 + 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 + + # 先保存本地,再加入上传队列(非阻塞)。上传失败也不会丢照片。 + 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连接(启动时尝试连接) +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到真实学号的映射表 --- +face_id_mapping = {} # 格式: {"1": "20210001", "2": "20210002"} +MAPPING_FILE = "/sd/face_mapping.txt" # SD卡上的映射文件 +SYNC_VERSION_FILE = "/sd/sync_version.txt" # 本地已应用的服务端同步版本 +RECORD_PATH = "/sd/recordftr.txt" # 人脸特征记录文件路径 +sync_last_version = 0 + +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: + 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卡加载本地已同步版本""" + 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): + """保存本地已同步版本""" + 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) + face_id_mapping[face_id] = new_student_id + save_local_mapping() + + 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 + +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请求 + http_request = "GET {} HTTP/1.1\r\nHost: {}:{}\r\nX-Api-Token: {}\r\nConnection: close\r\n\r\n".format( + url_path, SERVER_IP, SERVER_PORT, API_TOKEN) + + # 使用AT+CIPSEND发送 + print("[HTTP_GET] 准备发送 {} bytes".format(len(http_request))) + cmd = "AT+CIPSEND={}".format(len(http_request)) + uart2.write(cmd + "\r\n") + utime.sleep_ms(100) + + # 等待 > 提示符 + 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 + 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 + # 检查是否收到完整响应(有JSON且连接关闭) + if "}" in resp and "{" in 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'))) + + # 提取JSON + json_start = resp.find('{"') + if json_start != -1: + json_end = resp.rfind('}') + if json_end != -1 and json_end > json_start: + result = resp[json_start:json_end+1] + print("[HTTP_GET] 提取JSON成功: {}".format(result[:100])) + return result + + print("[HTTP_GET] 未找到JSON") + return None + +def sync_face_id_mapping(): + """从服务器同步人脸ID映射配置并更新本地记录""" + global face_id_mapping + + if not wifi_connected: + print("[SYNC] WiFi not connected, skip sync") + return False + + try: + print("[SYNC] ========== 开始同步映射配置 ==========") + print("[SYNC] 请求: GET /api/v1/faces/mappings") + response = esp_http_get("/api/v1/faces/mappings", 2000) + + if response is None: + print("[SYNC] 服务器无响应") + return False + + print("[SYNC] 收到响应: {}".format(response[:150] if len(response) > 150 else response)) + + # 简单解析JSON + mappings_start = response.find('"mappings":{') + if mappings_start == -1: + print("[SYNC] 响应中无mappings字段") + return False + + mappings_start += len('"mappings":{') + mappings_end = response.find('}', mappings_start) + if mappings_end == -1: + return False + + mappings_str = response[mappings_start:mappings_end] + print("[SYNC] 解析映射字符串: {}".format(mappings_str)) + + # 解析键值对 + server_mappings = {} + parts = mappings_str.split(',') + for part in parts: + part = part.strip() + if ':' in part: + kv = part.split(':') + if len(kv) == 2: + key = kv[0].strip().strip('"') + value = kv[1].strip().strip('"') + if key and value: + server_mappings[key] = value + + print("[SYNC] 服务器映射数量: {}".format(len(server_mappings))) + for k, v in server_mappings.items(): + print("[SYNC] 人脸ID: {} -> 学号: {}".format(k, v)) + + if len(server_mappings) == 0: + print("[SYNC] 服务器无映射配置") + face_id_mapping = {} + save_local_mapping() + return True + + # 对比本地映射 + print("[SYNC] 本地映射数量: {}".format(len(face_id_mapping))) + for k, v in face_id_mapping.items(): + print("[SYNC] 本地: {} -> {}".format(k, v)) + + # 只更新 face_id -> 学号映射,保持 recordftr.txt 里的原始 face_id 稳定。 + updated_count = 0 + for face_id, student_id in server_mappings.items(): + print("[SYNC] 检查是否需要更新: ID:{} -> {}".format(face_id, student_id)) + if update_face_record(face_id, student_id): + updated_count += 1 + + # 更新内存中的映射表 + face_id_mapping = server_mappings + save_local_mapping() + + print("[SYNC] ========== 同步完成,更新了 {} 条记录 ==========".format(updated_count)) + return True + + except Exception as e: + print("[SYNC] 同步出错:", e) + return False + +def extract_face_id(face_name): + face_name = str(face_name) + if face_name.startswith("ID:"): + return face_name.split(":", 1)[1] + return face_name + +def get_mapped_student_id(face_name): + 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 + + 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: + with open(record_path, "w") as f: + for p in peoples: + f.write(p.name + '#' + str(p.feature) + "\n") + 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) + + # 同时删除映射 + 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 + +def esp_http_post_json(url_path, json_data, timeout=2000): + """发送HTTP POST请求(优化版)""" + if not wifi_connected: + 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/json\r\nX-Api-Token: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}".format( + url_path, SERVER_IP, SERVER_PORT, API_TOKEN, len(json_data), json_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 "success" in resp or "}" in resp: + break + utime.sleep_ms(10) + + esp_send_cmd("AT+CIPCLOSE", 300) + return resp + +def sync_pending_delete(): + """从服务器同步待删除的人脸列表""" + if not wifi_connected: + print("[DELETE_SYNC] WiFi未连接,跳过") + return False + + try: + print("[DELETE_SYNC] ========== 开始同步删除列表 ==========") + print("[DELETE_SYNC] 请求: GET /api/v1/faces/pending-delete") + response = esp_http_get("/api/v1/faces/pending-delete", 8000) + + if response is None: + print("[DELETE_SYNC] 服务器无响应") + return False + + print("[DELETE_SYNC] 收到响应: {}".format(response)) + + # 解析JSON + start = response.find('"pending_delete":[') + if start == -1: + print("[DELETE_SYNC] 响应中无pending_delete字段") + return True + + start += len('"pending_delete":[') + end = response.find(']', start) + if end == -1: + return False + + arr_str = response[start:end] + print("[DELETE_SYNC] 待删除列表字符串: '{}'".format(arr_str)) + + if not arr_str.strip(): + print("[DELETE_SYNC] 无待删除的人脸") + return True + + # 解析数组 + ids_to_delete = [] + parts = arr_str.split(',') + for part in parts: + part = part.strip().strip('"') + if part: + ids_to_delete.append(part) + + print("[DELETE_SYNC] 待删除人脸ID列表: {}".format(ids_to_delete)) + + if not ids_to_delete: + print("[DELETE_SYNC] 解析后无待删除项") + return True + + # 显示当前本地人脸列表 + print("[DELETE_SYNC] 当前本地人脸数量: {}".format(len(peoples))) + for i, p in enumerate(peoples): + print("[DELETE_SYNC] [{}] {}".format(i, p.name)) + + # 执行删除(每次最多处理一个,避免长时间阻塞) + face_id = ids_to_delete[0] + print("[DELETE_SYNC] 正在删除: {}".format(face_id)) + + delete_result = delete_face_by_id(face_id) + print("[DELETE_SYNC] 删除结果: {}".format("成功" if delete_result else "失败")) + + if delete_result: + # 只有本地特征真正删除成功后才确认,避免服务端过早清掉待删除任务 + print("[DELETE_SYNC] 发送确认: POST /api/v1/faces/delete-ack") + confirm_result = esp_http_post_confirm(face_id) + print("[DELETE_SYNC] 确认结果: {}".format("成功" if confirm_result else "失败")) + else: + print("[DELETE_SYNC] 本地未删除成功,暂不确认,等待下次同步重试") + + # 显示删除后的人脸列表 + print("[DELETE_SYNC] 删除后本地人脸数量: {}".format(len(peoples))) + for i, p in enumerate(peoples): + print("[DELETE_SYNC] [{}] {}".format(i, p.name)) + + print("[DELETE_SYNC] ========== 删除同步完成 ==========") + return True + + except Exception as e: + print("[DELETE_SYNC] 同步出错:", e) + return False + +def esp_http_post_confirm(face_id): + """发送删除确认请求""" + if not wifi_connected: + return False + + try: + esp_send_cmd("AT+CIPCLOSE", 500) + utime.sleep_ms(200) + esp_send_cmd("AT+CIPMUX=0", 500) + + # 建立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("[CONFIRM] TCP连接失败") + return False + + # 构建POST请求 + json_data = '{{"face_id":"{}"}}'.format(face_id) + http_request = "POST /api/v1/faces/delete-ack HTTP/1.1\r\n" + http_request += "Host: {}:{}\r\n".format(SERVER_IP, SERVER_PORT) + http_request += "Content-Type: application/json\r\n" + http_request += "X-Api-Token: {}\r\n".format(API_TOKEN) + http_request += "Content-Length: {}\r\n".format(len(json_data)) + http_request += "Connection: close\r\n\r\n" + http_request += json_data + + # 发送请求 + cmd = "AT+CIPSEND={}".format(len(http_request)) + uart2.write(cmd + "\r\n") + utime.sleep_ms(100) + + # 等待>提示符 + start = utime.ticks_ms() + prompt = "" + while utime.ticks_diff(utime.ticks_ms(), start) < 2000: + if uart2.any(): + prompt += uart2.read().decode('utf-8', 'ignore') + if ">" in prompt: + break + utime.sleep_ms(10) + + if ">" not in prompt: + esp_send_cmd("AT+CIPCLOSE", 300) + return False + + # 发送数据 + uart2.write(http_request) + + # 等待响应 + resp = "" + start = utime.ticks_ms() + while utime.ticks_diff(utime.ticks_ms(), start) < 5000: + if uart2.any(): + data = uart2.read() + if data: + resp += data.decode('utf-8', 'ignore') + if "success" in resp: + print("[CONFIRM] 服务器确认成功") + esp_send_cmd("AT+CIPCLOSE", 300) + return True + utime.sleep_ms(20) + + esp_send_cmd("AT+CIPCLOSE", 300) + print("[CONFIRM] 响应: {}".format(resp[:100] if resp else "(空)")) + return "success" in resp + + except Exception as e: + print("[CONFIRM] 错误:", e) + return False + +def json_get_int(text, key, default=0): + 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 + +def extract_json_block(text, key, open_ch, close_ch): + 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 "" + +def parse_string_array_block(arr_str): + 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): + 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 + +def json_get_string(text, key, default=""): + 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 + +def parse_mapping_object(obj_str): + 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_mappings(server_mappings): + global face_id_mapping + print("[SYNC_STATE] 服务器映射数量: {}".format(len(server_mappings))) + updated_count = 0 + for face_id, student_id in server_mappings.items(): + print("[SYNC_STATE] 映射: ID:{} -> {}".format(face_id, student_id)) + if update_face_record(face_id, student_id): + updated_count += 1 + face_id_mapping = server_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 + + 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 + + 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: + 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 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: + 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失败,暂不推进本地版本,等待下次重试") + + mappings_obj = extract_json_block(response, "face_mappings", '{', '}') + server_mappings = parse_mapping_object(mappings_obj) + apply_face_mappings(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 + +def set_base_time(unix_timestamp): + """设置K210的基准时间""" + 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模型和硬件初始化 --- +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() +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.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)] +kpu.init_yolo2(task_fd, 0.5, 0.3, 5, anchor) +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): + 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 = [] + # 清空记录文件 + 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. 数据库和人脸管理函数 (已包含之前修复的bug) --- +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 parse_feature_vector(text): + """只解析浮点数组文本,避免执行SD卡中的任意代码。""" + allowed = "0123456789+-eE.,[] \t\r\n" + for ch in text: + if ch not in allowed: + raise ValueError("invalid char in feature") + + text = text.strip() + if not text.startswith("[") or not text.endswith("]"): + raise ValueError("feature must be list") + + inner = text[1:-1].strip() + if not inner: + raise ValueError("empty feature") + + values = [] + for token in inner.split(","): + token = token.strip() + if not token: + raise ValueError("empty number") + values.append(float(token)) + if len(values) > 512: + raise ValueError("feature too long") + + if len(values) < 64: + raise ValueError("feature too short") + return values + +def load_faces(): + global peoples; peoples = [] + try: + with open(record_path, "r") as f: + for line in f: + # 使用 maxsplit=1 参数,只按第一个 '#' 分割,防止特征数据中的'#'导致错误 + parts = line.strip().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: print("Feature parse error on line for {}: {}".format(parts[0], e_eval)) + print("成功加载 %d 条人脸数据" % len(peoples)) + except Exception as e: print("读取人脸数据库失败:", e) + +def collect_used_face_ids(): + """收集本地已经占用的K210原始face_id,避免新增时覆盖旧人脸。""" + used = {} + + for p in peoples: + name = str(p.name) + if name.startswith("ID:") and name.split(":", 1)[1].isdigit(): + used[name.split(":", 1)[1]] = 1 + + for face_id in face_id_mapping.keys(): + face_id = str(face_id) + if face_id.isdigit(): + used[face_id] = 1 + + 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(): + 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): + 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 + '#' + str(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): + if not (0 <= idx < len(peoples)): return False + deleted_name = peoples[idx].name + peoples.pop(idx) + try: + with open(record_path, "w") as f: + for p in peoples: f.write(p.name + '#' + str(p.feature) + "\n") + 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): + 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] + 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} + 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 +last_sent_data = "" # 改为记录完整数据而不是state +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卡照片,补传可能遗漏到服务器的照片""" + 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 + + # 优先处理照片上传 + 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 +last_sync_check_time = 0 + +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. 主循环 (状态机) --- +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: + 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": + img.draw_string(rect[0], rect[1], "Stranger", color=(255, 0, 0), scale=2) + recognized_ids.append("0"); has_stranger = True + else: + # 使用映射后的学号显示 + 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 + 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: + # 发送第一个识别到的人脸数据(可以改为发送得分最高的) + best_face = max(detected_faces, key=lambda x: x['score']) + + if best_face['state'] == "stranger": + # 陌生人,发送UNKNOWN + msg_to_send = "UNKNOWN\r\n" + + # 陌生人不保存到SD卡 + last_saved_id = "" # 重置保存记录 + else: + # 已识别用户,发送完整信息(STM32期望的格式) + 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, send UNKNOWN".format(face_id)) + msg_to_send = "UNKNOWN\r\n" + last_saved_id = "" + if msg_to_send != last_sent_data: + uart.write(msg_to_send) + print("[UART SEND] -> {}".format(msg_to_send.strip())) + last_sent_data = msg_to_send + last_send_time = time_now + lcd.display(img) + continue + student_id = mapped_student_id + student_name = mapped_student_id + confidence = best_face['score'] / 100.0 + + # 构造STM32期望的格式:ID:<学号>,NAME:<学号>,CONF:0.95 + msg_to_send = "ID:{},NAME:{},CONF:{:.2f}\r\n".format( + student_id, + student_name, + confidence + ) + + if msg_to_send != last_sent_data: + 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: + # 【新】如果是可以添加的陌生人,画一个绿色的框 + 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: + 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帧检查一次网络任务(只处理照片上传和同步请求) + 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() +